profileRyan KesPGP keyI build stuffEmailGithubTwitterLast.fmMastodonMatrix

TypeScript Discriminated Union

Description

If your class has a literal member then you can use that property to discriminate between union members.

Examples

interface Square {
  kind: "square"
  size: number
}

interface Rectangle {
  kind: "rectangle"
  width: number
  height: number
}
type Shape = Square | Rectangle

function area(s: Shape) {
  if (s.kind === "square") {
    // Now TypeScript *knows* that `s` must be a square ;)
    // So you can use its members safely :)
    return s.size * s.size
  } else {
    // Wasn't a square? So TypeScript will figure out that it must be a Rectangle ;)
    // So you can use its members safely :)
    return s.width * s.height
  }
}

Exhaustive Checks

Quite commonly you want to make sure that all members of a union have some code(action) against them. You can do that by simply adding a fall through and making sure that the inferred type in that block is compatible with the never type. For example if you add the exhaustive check you get a nice error:

interface Square {
  kind: "square"
  size: number
}

interface Rectangle {
  kind: "rectangle"
  width: number
  height: number
}

// Someone just added this new `Circle` Type
// We would like to let TypeScript give an error at any place that *needs* to cater for this
interface Circle {
  kind: "circle"
  radius: number
}

type Shape = Square | Rectangle | Circle

function area(s: Shape) {
  if (s.kind === "square") {
    return s.size * s.size
  } else if (s.kind === "rectangle") {
    return s.width * s.height
  } else if (s.kind === "circle") {
    return Math.PI * s.radius ** 2
  } else {
    // Okay once more
    const _exhaustiveCheck: never = s
  }
}

Switch

This can also be accomplished with a switch statement:

function area(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size
    case "rectangle":
      return s.width * s.height
    case "circle":
      return Math.PI * s.radius * s.radius
    default:
      const _exhaustiveCheck: never = s
  }
}

Exhaustive checks

Another solution is to write a function that only accepts a never and then throws an error if its body every executes:

function assertNever(x: never): never {
  throw new Error("Unexpected value. Should have been never.")
}

interface Square {
  kind: "square"
  size: number
}
interface Rectangle {
  kind: "rectangle"
  width: number
  height: number
}
type Shape = Square | Rectangle

function area(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size
    case "rectangle":
      return s.width * s.height
    // If a new case is added at compile time you will get a compile error
    // If a new value appears at runtime you will get a runtime error
    default:
      return assertNever(s)
  }
}

Retrospective Versioning

And after you have a bunch of DTOs you realize that name was a poor choice. You can add versioning retrospectively by creating a new union with literal number (or string if you want) of DTO. Mark the version 0 as undefined and if you have strictNullChecks enabled it will just work out:

type DTO =
  | {
      version: undefined // version 0
      name: string
    }
  | {
      version: 1
      firstName: string
      lastName: string
    }
  // Even later
  | {
      version: 2
      firstName: string
      middleName: string
      lastName: string
    }

function printDTO(dto: DTO) {
  if (dto.version == null) {
    console.log(dto.name)
  } else if (dto.version == 1) {
    console.log(dto.firstName, dto.lastName)
  } else if (dto.version == 2) {
    console.log(dto.firstName, dto.middleName, dto.lastName)
  } else {
    const _exhaustiveCheck: never = dto
  }
}