Understanding TypeScript Unions

18. July, 2023 9 min read Develop

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.

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:

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:

const greet = (names: string | string[]) => {
  if (Array.isArray(names)) {
    names.forEach(name => console.log(`Hello, ${name}!`));
  } else {
    console.log(`Hello, ${names}!`);
  }
};
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:

type DisplayProps = { value: number | string };

const Display: React.FC<DisplayProps> = ({ value }) => {
  return (
    <>
      {typeof value === 'number' ? (
        <p>Number: {value}</p>
      ) : (
        <p>String: {value}</p>
      )}
    </>
  );
};
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:

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.

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:

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

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

type User = Guest | RegisteredUser;
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:

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:

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:

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:

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

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:

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!