Bossy Lobster

A blog by Danny Hermes; musing on tech, mathematics, etc.

Edit on GitHub

Type Guards for Union Types in TypeScript

In my day job at Blend, I write a lot of TypeScript1. One great feature of TypeScript is the ability to specify an enum with a finite set of values as a union type:

type Coordinate = 'x' | 'y'

which then gives compile time checking for values of this type

const coordinate: Coordinate = 'x'
// This will not compile: const coordinate: Coordinate = 'donut'

Contents

What's Missing

Unfortunately, there is no utility built into the language that will provide an array of the values in the union type. I.e. for our Coordinate type, we often want:

const COORDINATES: Coordinate[] = ['x', 'y']
// Or even better, make it immutable: Object.freeze(['x', 'y'])

Such a COORDINATES array has many uses. It's common to use such an array to write a type guard

function isCoordinate(value: string): value is Coordinate {
  return COORDINATES.includes(value)
}

or to specify a Joi schema for an external facing API:

import * as Joi from '@hapi/joi'

const REPLACE_VALUE: Joi.ObjectSchema = Joi.object({
  id: Joi.string().guid().required(),
  coordinate: Joi.string().valid(COORDINATES).required(),
  value: Joi.number().required(),
})

What Can Go Wrong

It's easy enough to just hardcode COORDINATES to exactly agree with your Coordinate union type and feel happy that it's a comprehensive coverage of the values. Even better, since COORDINATES has the type Coordinate[], you have a guarantee2 from the tsc compiler that you won't have any invalid values.

However, let's say one day your codebase decides to expand from 2D into 3D:

type Coordinate = 'x' | 'y' | 'z'

None of the rest of your code will break, but it should have! Your COORDINATES covering set is no longer a covering set, but both x and y are valid. Calling isCoordinate('z') will return false (which is a lie) and your REPLACE_VALUE schema will reject calls to your API that want to replace the z value.

How Can We Fix It

By hardcoding COORDINATES we've accidentally made our codebase brittle. The ['x', 'y'] literal encodes an assumption about our code that is not checked anywhere at all. Also, the tsc compiler has no hope in helping us because COORDINATES is a value, not a type, so tsc isn't able to make any extra assertions to act as a guard rail.

Unit tests to the rescue! We can write a single unit test (with ava) that is guaranteed to fail if either the Coordinate union type or the COORDINATES value is changed:

import test from 'ava'

test('COORDINATES covers the Coordinate union type', t => {
  const asKeys: Record<Coordinate, number> = { x: 0, y: 0 }
  const expectedKeys = Object.keys(asKeys).sort()
  // NOTE: Sort `COORDINATES` without mutating it.
  const actualKeys = COORDINATES.concat().sort()
  t.deepEqual(expectedKeys, actualKeys)
})

Using the Record<> type allows the compiler to tell us if any members of the Coordinate are absent keys in asKeys. Then at runtime we use Object.keys() to convert those (already compiler checked) keys into a value expectedKeys. Then we can ensure that expectedKeys is verified against COORDINATES.

But What About ...

The snippet in the unit test absolutely provides a template for doing this inline (i.e. without the support of a unit test):

type Coordinate = 'x' | 'y'
const asKeys: Record<Coordinate, number> = { x: 0, y: 0 }
const COORDINATES: Coordinate[] = Object.keys(asKeys)

however, this snippet of code will fail due to the return type of Object.keys():

$ tsc snippet.ts
snippet.ts:6:7 - error TS2322: Type 'string[]' is not assignable to type 'Coordinate[]'.
  Type 'string' is not assignable to type 'Coordinate'.

6 const COORDINATES: Coordinate[] = Object.keys(asKeys)
        ~~~~~~~~~~~


Found 1 error.

So in order to use it, you'd need to resort back to a type assertion (and IMO type assertions should be avoided at all costs).

Additionally, though declaring asKeys only takes up one line, it's a bit of an eyesore in source code (vs. test code). As the number of allowed values in a given union type goes up, declaring asKeys inline will look even worse.

I've also had cases where I had a use in my code for a mapping identical to what was provided by Record<>. It's equally fine to define that mapping directly and derive the Coordinate union type from it via the keyof keyword

interface Point {
  x: number
  y: number
}
type Coordinate = keyof Point

Then the unit test will change ever so slightly3

const asKeys: Point = { x: 0, y: 0 }

In codebases where "no magic constants" is a rule4, a convenience enum can be provided to give named variables for each value in the Coordinate type:

enum CoordinateNames {
  x = 'x',
  y = 'y',
}
type Coordinate = keyof typeof CoordinateNames

Since a TypeScript enum is really just an object, we can use it directly in our unit test without having to form the stand-in asKeys value

const expectedKeys = Object.keys(CoordinateNames).sort()
  1. This may surprise many of my colleagues from the Python world
  2. Provided you don't use any as Coordinate[] type assertion funny business
  3. In cases where some of the keys are optional, a Required<Point> must be used for the type of asKeys. The Required<> type was added in TypeScript 2.8.)
  4. I.e. typing 'x' or 'y' would not be allowed

Comments