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.














