TypeScript, Discriminated Unions, and the “as” Prop Problem
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
- 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. - Maintainability
Usingswitch
or direct discriminant checks tends to be more maintainable. It communicates intent and avoids edge cases with spreads. - Performance
While spreading objects is generally fine, avoiding it where unnecessary can reduce shallow copies.switch
or direct access avoids allocations. - 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.
Comments ()