alligator's blog

narrowing discriminated unions in typescript

Mar 13, 2021

Here is a solution to a problem I've had a few times in TypeScript. I have a discriminated union:

enum ObjKind {
  String,
  Number,
}

type ObjString = {
  kind: ObjKind.String;
  str: string;
}

type ObjNumber = {
  kind: ObjKind.Number;
  num: number;
}

type Obj = ObjString | ObjNumber;

and I want a function checkObj that, if called like this:

const someObj1: Obj = ...;
const someObj2: Obj = ...;
const str = checkObj(someObj1, ObjKind.String);
const num = checkObj(someObj2, ObjKind.Number);

returns the correct type from the union. That is, I want str to be an ObjString and num to be an ObjNumber.

the first attempt

This version of checkObj does not do this, but we'll build on it:

function checkObj(obj: Obj, kind: ObjKind): Obj {
  if (obj.kind !== kind) {
    throw new Error('type mismatch!');
  }
  return obj;
}

This checks the type, but returns an Obj. To get the correct type, the caller needs to add a type assertion:

const str = checkObj(someObj, ObjKind.String) as ObjString;

That relies on manually matching the ObjKind to the assertion, how do we get the type checker to do this for us?

the solution

To do this, we need to change the signature of the function. First we move ObjKind into a type parameter:

function checkObj<K extends ObjKind>(
  obj: Obj,
  kind: K,
)

This allows us to use it in the return type, which becomes this:

function checkObj<K extends ObjKind>(
  obj: Obj,
  kind: K,
): Extract<Obj, { kind: K }> {

Extract is a utility type that will get a type from a union that can be assigned to another type. We give it Obj, the union we want to extract a type from, and { kind: K }, the type we want to extract. This will give us any type in the union where the kind parameter is set to K.

All that's left to do is add a type assertion on the return value and we're done:

return obj as Extract<Obj, { kind: K }>;

Here's the finished function:

function checkObj<K extends ObjKind>(
  obj: Obj,
  kind: K,
): Extract<Obj, { kind: K }> {
  if (obj.kind !== kind) {
    throw new Error('type mismatch!');
  }
  return obj as Extract<Obj, { kind: K }>;
}

Hope that helps someone. Usually when I see types like this in TypeScript my eyes glaze over, but it was worth persisting a little to make more use of the type checker.

blog index