Typing component props: discriminated unions over boolean flags

Most design-system bugs we read in reviews are not rendering bugs. They are prop combinations nobody intended to allow. A Button accepts loading, disabled, icon, and iconOnly; four booleans describe sixteen states, of which maybe five are meaningful, and the component's body is a stack of conditionals defending against the other eleven. TypeScript signed off on all sixteen, because each boolean is individually valid.

This is a modelling problem, not a typing problem, and discriminated unions are the tool for it. The point is not type-system elegance. It is that the compiler starts rejecting calls that used to reach code review, and the component body loses its defensive branches.

The failure mode

A typical props interface after eighteen months of feature requests:

interface NotificationProps {
  variant: 'info' | 'error' | 'success';
  message: string;
  dismissible?: boolean;
  onDismiss?: () => void;
  autoHideMs?: number;
  actionLabel?: string;
  onAction?: () => void;
}

Every meaningful constraint here is unwritten. onDismiss is required when dismissible is true and meaningless otherwise. actionLabel and onAction only make sense together. autoHideMs contradicts dismissible in most designs. None of that is expressed, so it lives in the component body as if (dismissible && !onDismiss) return null and in a paragraph of Storybook prose that nobody reads at the call site.

The cost shows up later: a dismissible notification with no handler ships, renders a dismiss button, and does nothing when clicked. That is a runtime bug the type checker had all the information to prevent.

Model the states, then type them

Write down the states the component actually has. For the notification above there are three: passive, dismissible, actionable. Give each a discriminant and put the state-specific props inside it.

type NotificationBase = {
  variant: 'info' | 'error' | 'success';
  message: string;
};

type NotificationProps = NotificationBase &
  (
    | { behavior: 'passive'; autoHideMs?: number }
    | { behavior: 'dismissible'; onDismiss: () => void }
    | { behavior: 'action'; actionLabel: string; onAction: () => void }
  );

Three things follow immediately. Passing behavior: 'dismissible' without onDismiss is now a compile error at the call site rather than a silent no-op in production. Narrowing on props.behavior inside the component gives you onDismiss as a defined function with no non-null assertion and no fallback branch. And the union is the documentation — an engineer reading the type sees three supported shapes instead of seven optional properties and a guess.

The discriminant should be a string literal, not a boolean. behavior: 'dismissible' narrows cleanly and extends to a fourth state without touching the first three; dismissible: true | false does not extend at all.

Excess property checks are doing more work than you think

One subtlety worth knowing before you rely on this. TypeScript's excess property check fires on object literals, so JSX attributes are checked — <Notification behavior="passive" onDismiss={fn} /> is an error. But if the props are assembled into a variable first and spread, the check relaxes, and a stray onDismiss can pass. Spreading assembled prop objects into components is common in table and form abstractions; it is also where union enforcement quietly stops applying. Prefer explicit JSX attributes at call sites that matter, and type the intermediate object as the union rather than inferring it.

Polymorphic components: pay the complexity only where it earns

The other place props typing goes wrong is the as prop — a Box or Text that can render as any element and should accept that element's attributes. It is expressible:

type TextProps<E extends React.ElementType> = {
  as?: E;
  size?: 'sm' | 'md' | 'lg';
} & Omit<React.ComponentPropsWithoutRef<E>, 'as' | 'size'>;

function Text<E extends React.ElementType = 'span'>({ as, size = 'md', ...rest }: TextProps<E>) {
  const Component = as ?? 'span';
  return <Component {...rest} />;
}

This works, and it costs you: slower editor completions in large files, error messages that name internal helper types rather than your mistake, and a component signature that is now a small piece of type-level engineering to maintain. Our rule is that generics need a caller who needs them. Two or three polymorphic primitives at the base of a design system is reasonable. A polymorphic ProductCard is not — it has one sensible element, and React.ComponentPropsWithoutRef<'article'> says so in one line that anyone can read.

The same judgement applies to conditional and mapped types in a props interface. If the error message a colleague sees at 5pm does not name the actual problem, the type is not paying for itself.

What React 19 changed

Two changes matter for component APIs and are worth handling as part of an upgrade rather than as a separate cleanup.

ref is now a regular prop for function components. forwardRef still works, but new components do not need it, and the type moves into your props: ref?: React.Ref<HTMLButtonElement>. For a design system this removes a wrapper layer from every forwarding primitive, which also removes the ForwardRefExoticComponent types that made those components awkward to extend generically.

The @types/react 19 line also tightened several definitions that were previously loose — useRef requires an argument, ReactNode no longer silently admits some values it used to, and JSX moved into the React namespace. On a large codebase this surfaces as a batch of new errors on the day you upgrade the types, most of them mechanical. Run the types upgrade as its own commit, separate from the runtime upgrade, so the diff is reviewable and a revert is cheap.

If you are adopting React 19's form actions, note that useActionState types the state and the action together; the pattern rewards giving the action's return value a named union type with an explicit error shape, for the same reason as everything above — the states are enumerated, so the consuming component can narrow instead of guess.

Where to start on an existing codebase

Do not open a ticket to type the design system. Take the three components with the most boolean props — a script over your component sources will find them faster than memory will — and convert those. Each conversion is a mechanical change at call sites and a deletion of defensive branches inside. Ship them separately.

Then watch what happens to the pull requests that follow. The measurable outcome is not fewer type errors; it is fewer review comments of the form "this combination doesn't do what you think". If that class of comment does not drop, the union was not modelling anything real, and you should say so rather than continue on principle.