System preferences

Back

SECTOR 02.1Transmission open

On TypeScript discriminated unions

You typed a response as a union. Then you read a field off it and the compiler stopped you.

CODE // TRANSMISSION01 LINE
Property 'a' does not exist on type 'A', 'B'

The union is not wrong. The narrowing is missing. A union declares what a value could be. Discrimination decides what it is right now.

Multi-type entities show up every day. API responses adopt several shapes. Collections carry several types. Parsed user input collapses to different scalars. Unions describe all of them.

CODE // TRANSMISSION01 LINE
const idk = string | number | Potato

For primitives TypeScript does the work alone. A plain if-else narrows the value and the inference follows.

CODE // TRANSMISSION05 LINES
if (typeof idk === 'string') {
  // idk: String
} else {
  // idk: number | Potato
}

Objects are where the inference runs out. Take the steps in this order.

  1. By default, let TypeScript infer.
  2. For simple types, any if-else will do.
  3. For objects, discriminate on an identifier field.
  4. Without an identifier field, run a morphic check.
  5. As a last resort, write a type predicate.

Two coins carry the examples from here on.

The recommended way is a constant string-type field in the object. TypeScript anchors on that field and infers the type, the same way it does with primitives.

CODE // TRANSMISSION13 LINES
type VaporCoin = { type: 'vapor' }
type NeonCoin = { type: 'neon' }

const act = (coin: VaporCoin | NeonCoin) => {
  switch (coin.type) {
    case 'vapor': {
      // coin: VaporCoin
    }
    case 'neon': {
      // coin: NeonCoin
    }
  }
}

A morphic check reads the shape

Sometimes no single field is reliable. Sometimes the shapes are not yours to decide, as with a third-party API. Then infer the type by running a morphic check. The check looks for a difference in shape.

CODE // TRANSMISSION10 LINES
type VaporCoin = { vapor: string }
type NeonCoin = { neon: string }

const act = (coin: VaporCoin | NeonCoin) => {
  if ('vapor' in coin) {
    // coin: VaporCoin
  } else {
    // coin: NeonCoin
  }
}

A type predicate is the last resort

Everything else failed. The objects carry no identifier field and they are morphally equal. Only their inner values differ. So check those values in a function and let the function coerce the type. TypeScript calls these type predicates.

CODE // TRANSMISSION14 LINES
type VaporCoin = { key: string }
type NeonCoin = { key: string }

const isVapor = (tbd: unknown): tbd is VaporCoin => {
  return tbd.key === 'vapor'
}

const act = (coin: VaporCoin | NeonCoin) => {
  if (isVapor(coin)) {
    // coin: VaporCoin
  } else {
    // coin: NeonCoin
  }
}

The coercion here is imperative. Your function decides the type, not the inference. That is the price of step 5, and it is why step 5 comes last.

Take the first step that compiles. Never open with step 5.