Angelo Dini, Switzerland. Head of Product at Divio, co-founder of devguard.

Understanding TypeScript Unions

A practical guide to TypeScript union types in React, covering narrowing, discriminated unions, intersections and the pitfalls that quietly cost you time.

By 9 min read

Article
Article 039 of 77
Category
Teach
Steps
6
Code samples
TypeScript, TSX

The pipe character does a lot of work

A union type is the smallest possible piece of TypeScript syntax and one of the easiest to get subtly wrong. You write a pipe between two types and the compiler starts asking questions you didn't expect.

A shape-sorter box taken apart: the box, its lid with a round, a square, a triangular and a hexagonal hole, a round and a square block above their holes and the hexagonal block lifted out in orange.
Fig. 039 A shape-sorter box taken apart: the box, its lid with a round, a square, a triangular and a hexagonal hole, a round and a square block above their holes and the hexagonal block lifted out in orange.

I want to go through how I actually use unions in React work: on props, on reducer actions, and in the places where the compiler stops helping and you have to give it a hand. There is also a small pile of things I got wrong for longer than I’d like to admit, so those get their own section at the end.

What a union actually is

A union says a value is one of several types, but not which one. You write it with a pipe:

TypeScript
type MyUnion = number | string;

MyUnion holds either a number or a string. The important half of that sentence is the part people skip: until you prove which one it is, TypeScript will only let you use the members that are common to both. .toFixed() is off limits, .toUpperCase() is off limits, and .toString() is fine because both have it.

Proving which one it is called narrowing, and it is most of the work.

Function parameters

The classic case is a parameter that accepts one thing or a list of them:

TypeScript
const greet = (names: string | string[]) => {
  if (Array.isArray(names)) {
    names.forEach(name => console.log(`Hello, ${name}!`));
  } else {
    console.log(`Hello, ${names}!`);
  }
};
TypeScript
greet('Alice');
// output: Hello, Alice!

greet(['Bob', 'Charlie']);
// output: Hello, Bob! Hello, Charlie!

Inside the if, names is string[]. Inside the else, it’s string. Array.isArray is one of the checks TypeScript understands natively, along with typeof, instanceof, in, and a plain truthiness test.

Conditional rendering in React

Same idea, applied to props. A component that renders differently depending on what it was handed:

TSX
type DisplayProps = { value: number | string };

const Display: React.FC<DisplayProps> = ({ value }) => {
  return (
    <>
      {typeof value === 'number' ? (
        <p>Number: {value}</p>
      ) : (
        <p>String: {value}</p>
      )}
    </>
  );
};
TSX
const App = () => {
  return (
    <>
      <Display value={42} />
      {/* output: Number: 42 */}

      <Display value="Hello, TypeScript!" />
      {/* output: String: Hello, TypeScript! */}
    </>
  );
};

The typeof check does the narrowing, so both branches know what they’re holding.

Where unions really earn their place on props is string literals. variant: 'primary' | 'secondary' | 'danger' gives you autocomplete in the editor and a compile error on a typo, which is roughly a hundred times more useful than variant: string and costs nothing.

Reducer actions

This is where I use unions most, and it’s the shape that made them click for me. A reducer takes one of a fixed set of actions, and the action’s type field tells you which:

TypeScript
type CounterAction =
  | { type: 'increment'; payload: number }
  | { type: 'decrement'; payload: number }
  | { type: 'reset' };

type CounterState = { count: number };

Note that reset has no payload at all. That’s deliberate, and it’s the part a single flat interface with optional fields cannot express. With { type: string; payload?: number } you can dispatch { type: 'reset', payload: 42 } and nobody stops you.

TypeScript
const counterReducer = (
  state: CounterState,
  action: CounterAction
): CounterState => {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + action.payload };
    case 'decrement':
      return { ...state, count: state.count - action.payload };
    case 'reset':
      return { ...state, count: 0 };
  }
};

Inside the increment case, action.payload is a number and the compiler knows it. Inside reset, reaching for action.payload is an error, because that member has no such field.

Discriminated unions

What makes the reducer above work is that every member has a property with a literal type, and the literals are all different. TypeScript calls that a discriminant, and a union built this way is a discriminated union. Switch on it and each case narrows the whole object rather than only the field you tested.

The discriminant does not have to be called type and does not have to be a string. Booleans work fine, which is handy for a two-state shape:

TypeScript
type Guest = {
  id: string;
  isGuest: true;
};

type RegisteredUser = {
  id: string;
  isGuest: false;
  email: string;
};

type User = Guest | RegisteredUser;
TypeScript
const getContact = (user: User) => {
  if (user.isGuest) {
    return null;
  }

  return user.email;
};

Note isGuest: true, not isGuest: boolean. That distinction is the entire mechanism. A boolean field is not a discriminant, it’s just a field, and narrowing will not happen.

Exhaustiveness checking

Here’s the feature that pays for the rest of it. Assign the narrowed value to never in the default branch:

TypeScript
const counterReducer = (
  state: CounterState,
  action: CounterAction
): CounterState => {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + action.payload };
    case 'decrement':
      return { ...state, count: state.count - action.payload };
    case 'reset':
      return { ...state, count: 0 };
    default: {
      const unhandled: never = action;
      throw new Error(`Unhandled action: ${unhandled}`);
    }
  }
};

If every case is covered, action in the default branch has type never and the assignment compiles. The moment somebody adds { type: 'set'; payload: number } to CounterAction and forgets the reducer, that line stops compiling and names the type it can’t assign. I’ve caught more bugs with this five-line pattern than with any amount of unit testing around reducers.

Combining unions with intersections

Unions and intersections are often introduced as a pair, which is a bit misleading because they do opposite things. A union is “one of these”. An intersection, written with &, is “all of these at once”.

They compose usefully. If several members share fields, factor the shared part out and intersect it with the union of the variable parts:

TypeScript
type WithId = { id: string };

type Guest = { kind: 'guest' };
type Registered = { kind: 'registered'; email: string };

type User = WithId & (Guest | Registered);

User is equivalent to (WithId & Guest) | (WithId & Registered), so id is available everywhere and email only appears once you’ve narrowed on kind. The intersection distributes over the union. For two members this is more ceremony than it’s worth, but at five or six shared fields it stops the shape from drifting apart.

Distribution, and why Exclude works

Most of the utility types you already use are built on the fact that conditional types distribute over unions. Exclude<'a' | 'b' | 'c', 'a'> gives 'b' | 'c' because the condition is applied to each member separately and the results are unioned back together.

That’s worth knowing mostly so you’re not surprised by it. It’s how you take an existing prop union and derive a narrower one, rather than writing the list out twice and letting the two copies drift.

Pitfalls

A union of object types without a discriminant

This is the mistake I made for a long time:

TypeScript
type Success = { data: string };
type Failure = { error: string };

type Result = Success | Failure;

It reads perfectly well and it is annoying to use. You cannot write result.data, because Failure has no data. There is nothing to switch on. You end up with 'data' in result checks scattered through the code, which works but reads like an apology.

Add a discriminant and the problem disappears:

TypeScript
type Result =
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

If you’re modelling API responses this way, it pairs nicely with the shape normalisation I wrote about in Using Normalizr: decide the shape once, at the boundary, and the rest of the app stops guessing.

Widening swallows your literals

TypeScript
type Size = 'sm' | 'md' | string;

That looks like “one of these two, or any other string”. It is not. 'sm' and 'md' are both assignable to string, so the whole thing collapses to string and your autocomplete quietly disappears. There’s no error, which is what makes it nasty.

If you genuinely want the suggestions plus an escape hatch, the workaround is to stop the collapse:

TypeScript
type Size = 'sm' | 'md' | (string & {});

It’s ugly. It works, the editor keeps suggesting sm and md, and I use it perhaps twice a year.

Too many members

A union with fifteen members is a signal, not an achievement. Somewhere in there is a second concept trying to get out. Split it, or find the field the members actually vary on and make that the discriminant.

The same thing applies to a component whose props are a union of four completely different shapes. That’s usually four components sharing a name because nobody wanted to pick three more. Some of this is just idiomatic programming applied to types: the clearest version is the one the next person can read without a map.

Where I’d start

If you take unions no further than string literal props and one discriminated reducer action type with a never check in the default branch, you have most of the value. Everything above that is refinement.

The one thing I’d add on top is the habit of asking, whenever a field is optional, whether it’s actually optional or whether there are two shapes hiding behind one interface. It usually is two shapes.

‘Till next time!

Steps in this article

  1. 01What a union actually is
  2. 02Reducer actions
  3. 03Discriminated unions
  4. 04Combining unions with intersections
  5. 05Pitfalls
  6. 06Where I’d start