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

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI