TypeScript, Discriminated Unions, and the “as” Prop Problem

TypeScript, Discriminated Unions, and the “as” Prop Problem
Photo by Antonio Sokic / Unsplash

If you’ve been working with TypeScript for a while, you’ve probably run into situations where the compiler feels a little too conservative. One common case is when working with discriminated unions and destructuring.

It looks neat at first glance:

type Action =
  | { kind: 'A'; payload: number }
  | { kind: 'B'; payload: string };

function example({ kind, ...rest }: Action) {
  if (kind === 'A') {
    rest.payload.toFixed();   // ❌ Error
  }
  if (kind === 'B') {
    rest.payload.toUpperCase(); // ❌ Error
  }
}

At runtime this code works perfectly fine. TypeScript, however, throws errors. Why? Because once you destructure kind out, the compiler loses the connection between kind and the rest of the fields. To TypeScript, rest.payload is now number | string, not correlated with kind.


Why TypeScript Behaves This Way

TypeScript’s narrowing rules are powerful but conservative. They work great if you leave the union intact:

function example(action: Action) {
  if (action.kind === 'A') {
    action.payload.toFixed();     // ✅ Works
  }
  if (action.kind === 'B') {
    action.payload.toUpperCase(); // ✅ Works
  }
}

Here, the compiler understands the discriminant (kind) and refines payload accordingly.

The moment you spread into { ...rest }, though, TypeScript treats it as a fresh object where the link between kind and payload is lost. There’s no correlation tracking between separated destructured parts.


Workarounds and Patterns

So what can you do if you like the destructuring style?

1. Don’t Destructure Discriminants

The simplest fix is: don’t separate them. Keep the discriminant and the correlated field in the same scope.

function example({ kind, payload }: Action) {
  if (kind === 'A') payload.toFixed();
  if (kind === 'B') payload.toUpperCase();
}

2. Use switch Statements

A switch handles discriminated unions beautifully and is often the cleanest style:

function example(action: Action) {
  switch (action.kind) {
    case 'A':
      action.payload.toFixed();
      break;
    case 'B':
      action.payload.toUpperCase();
      break;
  }
}

3. Type Guards

If you’re adamant about separating, you can define custom type guards:

function isActionA(action: Action): action is { kind: 'A'; payload: number } {
  return action.kind === 'A';
}

function example(action: Action) {
  if (isActionA(action)) {
    action.payload.toFixed(); // ✅
  }
}

This introduces some boilerplate, but it makes the intent explicit.


Why This Feels Familiar: The “as” Prop in React

This limitation pops up again in React when implementing polymorphic components with an as prop. For example:

type ButtonProps<T extends React.ElementType> = {
  as?: T;
  children: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;

function Button<T extends React.ElementType = 'button'>({
  as,
  children,
  ...props
}: ButtonProps<T>) {
  const Component = as || 'button';
  return <Component {...props}>{children}</Component>;
}

Here, we want TypeScript to infer different allowed props depending on whether as="a", as="div", etc. While possible with advanced generics, the ergonomics often break down when destructuring. The situation is strikingly similar: correlated props lose their narrowing once split apart.


Other Considerations

  1. Runtime vs. Type Safety
    At runtime, your code is correct. TypeScript errs on the side of caution to prevent accidental misuse, even if that blocks safe patterns.
  2. Maintainability
    Using switch or direct discriminant checks tends to be more maintainable. It communicates intent and avoids edge cases with spreads.
  3. Performance
    While spreading objects is generally fine, avoiding it where unnecessary can reduce shallow copies. switch or direct access avoids allocations.
  4. Future of TypeScript
    There’s an open TypeScript issue on “correlated rest destructuring” (#30581). If implemented, this exact pattern ({ kind, ...rest }) would narrow properly. For now, you’ll have to live with the trade-offs.

Finally

TypeScript gives us powerful tools for building safe applications, but sometimes the type system isn’t as expressive as the runtime reality. The destructured discriminant case is one of those gaps.

Until TypeScript supports correlated rest narrowing, the best practice is to:

  • Avoid splitting discriminants from their payloads,
  • Use switch statements or type guards,
  • For React’s as prop, lean on generic component props patterns.

It might feel like the compiler is fighting you, but really it’s nudging you toward patterns that are easier to reason about and safer in the long run.

Support Us