Max's Blog

TypeScript Discriminated Unions: Problems and Solutions

Disclaimer: Following solutions create new problems, including more types and sometimes more JavaScript.

Problem: Union properties don't narrow

declare function assertType<T>(arg: T);

interface Circle {
  kind: 'circle';
}
interface Square {
  kind: 'square';
}

interface House<TDoor> {
  door: TDoor;
}

declare const house: House<Circle | Square>;

if (house.door.kind === 'circle') {
  // Argument of type 'House<Circle | Square>' is not assignable to parameter of
  // type 'House<Circle>'.
  //                        👇👇👇
  assertType<House<Circle>>(house);
}

Solution 1: Narrow the property by itself, then reconstruct

declare const house: House<Circle | Square>;

const { door } = house;

if (door.kind === 'circle') {
  assertType<House<Circle>>({ door });
}

Solution 2: Union property to union the whole thing

declare function assertType<T>(arg: T);

type Shape = Circle | Square;

interface House<TDoor extends Shape = Shape> {
  kind: TDoor['kind'];
  door: TDoor;
}

declare const house: House<Circle> | House<Square>;

// Still does not work
if (house.door.kind === 'circle') {
  // Argument of type 'House<Circle> | House<Square>' is not assignable to parameter of
  // type 'House<Circle>'.
  //                        👇👇👇
  assertType<House<Circle>>(house);
}

// Using the new property we added does work though!
if (house.kind === 'circle') {
  assertType<House<Circle>>(house);
}

Related TypeScript issue: https://github.com/Microsoft/TypeScript/issues/27272

Problem: Discriminated union definitions are verbose

// So verbose! 👎 Too much boilerplate for a one-off use case.

interface Circle {
  kind: 'circle';
}

interface Square {
  kind: 'square';
}

type Shape = Circle | Square;

const data: Shape = { kind: 'circle' };

Solution: Skip defining types

// More pragmatic

type Shape2 = { kind: 'circle' } | { kind: 'square' };

const data2: Shape2 = { kind: 'circle' };

/* --- */

// Most pragmatic! 👍 Don't bother defining the types at all. We'll still get
// type errors if we mess up.
const data3 = { kind: 'circle' as const };

Problem: Pulling a property off a union is verbose

Solution: Define more propery types to allow direct property access

type Result<T> = { kind: 'ok'; value: T } | { kind: 'err'; msg: string };

declare const result: Result<number>;

// So verbose! 🤮 (More of my code looks like this than would ideal)
const value = result.kind === 'ok' ? result.value : undefined;

/* --- */

type Result2<T> =
  | { kind: 'ok'; value: T; msg?: undefined }
  | { kind: 'err'; value?: undefined; msg: string };

declare const result2: Result2<number>;

// Much better! 😌
const value2 = result2.value;

declare const result3: Result2<{ nested: string }>;

// And it works with optional chaining! 🎊
const value3 = result3.value?.nested;