Tech Verse Logo
Enable dark mode
React TypeScript Props: Unions, Generics, and Children

React TypeScript Props: Unions, Generics, and Children

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

•4 min read

Ditching Optional Prop Spaghetti for Discriminated Unions

If you've spent any time maintaining a design system button component, you've probably written a type definition like this:

type ButtonProps = {
  variant: 'link' | 'button';
  href?: string;
  onClick?: () => void;
  disabled?: boolean;
};

This type signature is a bug generator. It lets developers pass href when variant="button" or pass an onClick alongside href when rendering an anchor tag. The compiler won't complain, but your runtime render logic gets messy fast with defensive if checks.

Discriminated unions fix this by modeling mutually exclusive states directly in the type system. When working with react typescript props, breaking prop definitions into distinct variants gives you instant autocomplete and type safety at build time.

import React from 'react';

type BaseButtonProps = {
  children: React.ReactNode;
  className?: string;
};

type ActionButtonProps = BaseButtonProps & {
  variant: 'button';
  onClick: () => void;
  disabled?: boolean;
  href?: never;
};

type LinkButtonProps = BaseButtonProps & {
  variant: 'link';
  href: string;
  onClick?: never;
  disabled?: never;
};

type ButtonProps = ActionButtonProps | LinkButtonProps;

export function Button(props: ButtonProps) {
  if (props.variant === 'link') {
    return <a href={props.href} className={props.className}>{props.children}</a>;
  }

  return (
    <button onClick={props.onClick} disabled={props.disabled} className={props.className}>
      {props.children}
    </button>
  );
}

Notice how we set href?: never on ActionButtonProps. TypeScript won't just suggest the correct props based on variant—it will throw a red squiggly line if someone attempts to pass a link target to a standard button. This pattern saves teams from subtle runtime bugs when refactoring shared UI components across Next.js 16 routes.

Building Generic Components Without Losing Your Mind

Generic components often make developers flinch. Syntax like <T extends Record<string, unknown>> looks dense, but once you master the pattern, select boxes, tables, and dropdown menus become vastly easier to consume.

Suppose you have a custom Select component. You want the user to pass an array of items and an onChange handler that receives the exact type of item selected. Without generics, you fall back to any or force the consumer to cast types manually.

import React from 'react';

type SelectProps<T> = {
  items: T[];
  getKey: (item: T) => string | number;
  getLabel: (item: T) => string;
  value: T | null;
  onChange: (selected: T) => void;
  placeholder?: string;
};

export function Select<T>({
  items,
  getKey,
  getLabel,
  value,
  onChange,
  placeholder = 'Select an option'
}: SelectProps<T>) {
  return (
    <select
      value={value ? getKey(value) : ''}
      onChange={(e) => {
        const selected = items.find((item) => String(getKey(item)) === e.target.value);
        if (selected) onChange(selected);
      }}
    >
      <option value="" disabled>{placeholder}</option>
      {items.map((item) => (
        <option key={getKey(item)} value={getKey(item)}>
          {getLabel(item)}
        </option>
      ))}
    </select>
  );
}

Here is the beauty: consumers don't need to specify the generic parameter explicitly. TypeScript infers T directly from the items prop. If you pass an array of User objects, onChange automatically receives a typed User instance.

Gotcha: Arrow Functions and JSX Parser Ambiguity

If you prefer defining components with arrow functions, TSX syntax breaks generic parameter declarations because the parser mistakes <T> for an unclosed HTML tag. You have two fixes. Either add a trailing comma: const Select = <T,>(props: SelectProps<T>) => ... or use the standard function keyword. Stick to function declarations for generic components. It is cleaner, less cryptic, and avoids syntax hacks.

Explicit Children Typing in React 19

React 19 changed how we deal with refs and children types. If you're upgrading older codebases that used React.FC, you probably noticed that implicit children vanished completely. Good riddance.

When typing children, resist the urge to default to React.ReactNode for every scenario. ReactNode accepts strings, numbers, elements, fragments, portals, null, and undefined. That is great for layout containers, but terrible for components that expect specific child structures.

Matching the Right Type to the Job

  • React.ReactNode: Use for wrappers, cards, and layouts that can render any valid renderable content.
  • React.ReactElement: Use when your component expects exactly one single React element (for instance, when wrapping a child with slot patterns).
  • (data: T) => React.ReactNode: Use for render props patterns where the child component receives state from the parent.

By typing slot props as React.ReactElement instead of ReactNode, you prevent accidental bugs like passing plain strings or booleans where an interactive element (like a button or link) is strictly expected.

Extending Native HTML Attributes Safely

Building wrapper components over native elements like <input> or <button> is daily work for UI developers. The naive approach re-types standard HTML props manually. The experienced approach uses React.ComponentPropsWithoutRef or React 19's simplified React.ComponentProps.

In React 19, ref is passed as a standard prop rather than requiring React.forwardRef wrappers. This simplifies your typings when extending DOM elements.

By extending React.ComponentProps<'input'>, your custom input inherits every valid attribute from standard HTML input elements—including type, placeholder, onChange, disabled, and aria-* attributes—without manual declaration. Overriding conflicting properties with Omit<React.ComponentProps<'input'>, 'size'> keeps custom prop types unambiguous when your component redefines native prop names.

Trade-offs and Performance Pitfalls

Over-engineering types is a real trap in large TypeScript codebases. Complex conditional types inside prop definitions can slow down the TypeScript compiler in IDEs. When your TypeScript server takes 3 seconds to re-check a file on every keystroke, your team's velocity drops fast.

Prefer flat interfaces and discriminated unions over deep nested conditional type transformations. Keep generics simple and bounded. Your type system should catch bugs without requiring a degree to decipher.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Safe Tenant Isolation in Laravel Multi Database Apps

    Safe Tenant Isolation in Laravel Multi Database Apps

    Building Reliable LLM Fallback Chains with Python

    Building Reliable LLM Fallback Chains with Python

    Profiling Python Memory Usage with Memray

    Profiling Python Memory Usage with Memray

    Fixing Cascading Renders Through React State Colocation

    Fixing Cascading Renders Through React State Colocation

    Next.js Custom Image Loader for S3 and MinIO

    Next.js Custom Image Loader for S3 and MinIO

    React Form State with Server Actions

    React Form State with Server Actions

    Fixing Livewire State Loss During Network Drops

    Fixing Livewire State Loss During Network Drops

    Handling Concurrent Writes with Laravel Pessimistic Locking

    Handling Concurrent Writes with Laravel Pessimistic Locking

    Hardening a Linux VPS with Custom Port and Keys

    Hardening a Linux VPS with Custom Port and Keys

    Next.js Server Actions Concurrency and Stale Data

    Next.js Server Actions Concurrency and Stale Data