DEV Community

Cover image for Using discriminated union types in TypeScript
Antti Pitkänen
Antti Pitkänen

Posted on • Updated on

Using discriminated union types in TypeScript

The compile time vs runtime type dilemma

TypeScript is a typed language that compiles to JavaScript, which itself doesn't have types beyond the primitives. So when a developer writes TypeScript, it then gets transpiled into JavaScript, which is then executed in the runtime (e.g. a browser, or Nodejs on the server side). The process of transpilation leverages the static type information to catch a whole category of potential bugs, but the resulting runtime code is still just typeless JavaScript.

This has some implications that surprise developers coming to TypeScript from runtime typed languages. For one, you cannot check the type of anything at runtime, beyond the primitives. In practice:

// primitives work...
const myNumber = 1;
const myString = 'some string';

typeof myNumber; // -> "number"
typeof myString; // -> "string"

// ...but more sophisticated types don't
type MyType = {
  name: string;
  func: () => number;
}

const myValue: MyType = {
  name: 'some name for my type',
  func: () => 123
}

typeof myValue; // -> "object", nothing more specific than that
Enter fullscreen mode Exit fullscreen mode

The same code in TypeScript playground

The problem

So let's say we have two types, Cat and Dog, and an Animal type that's a union of both:

// all animals in our case share some base attributes
type BaseAnimal = {
  name: string;
  isFluffy: boolean;
}

// cats meow
type Cat = BaseAnimal & {
  meow: () => string;
}

// dogs bark
type Dog = BaseAnimal & {
  bark: () => string;
}

type Animal = Cat | Dog;

const whiskers: Cat = {
  name: 'Whiskers',
  isFluffy: true,
  meow: () => 'MEOWWW!' // Whiskers meows loudly
}

const pupper: Dog = {
  name: 'Pupper',
  isFluffy: false,
  bark: () => 'awooo' // Pupper has a gentle awoo instead of a loud bark
}

console.log(whiskers.meow()) // -> "MEOWWW!"
console.log(pupper.bark()) // -> "awooo"
Enter fullscreen mode Exit fullscreen mode

Now let's write some business logic for an Animal to make noise, regardless of whether it's a Cat or a Dog:

const makeNoise = (animal: Animal): string => {
  // ???
}
Enter fullscreen mode Exit fullscreen mode

TypeScript cannot really help you here, as seen in the screenshot.

TypeScript not being able to work on the meow and bark methods

Because of the compile time vs runtime behaviour of types, you cannot do something like this:

const makeNoise = (animal: Animal): string => {
  // Doesn't work because the type doesn't exist at runtime,
  // typeof will just return 'object'
  if (typeof animal === 'Dog') {
    return animal.bark();
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Instead you can do something like this, but TypeScript is not happy about it:

const makeNoise = (animal: Animal): string => {
  if (typeof animal.bark === 'function') {
    return animal.bark()
  }

  if (typeof animal.meow === 'function') {
    return animal.meow()
  }

  return 'not implemented!'
}

console.log(makeNoise(whiskers)) // -> "MEOWWW!"
console.log(makeNoise(pupper)) // -> "awooo"
Enter fullscreen mode Exit fullscreen mode

TypeScript's unhappiness comes from the fact that the .meow() and .bark() methods don't exist on all the variations of Animal. Here's what the errors look like:

TypeScript unable to work with partially accurate information

Even if you accepted this approach, what would happen if the complexity grows? Let's say we want to add a Cat.purr() and Dog.growl() method for the types, and add those to the makeNoise output too. The code would get harder to work with right away, with more complex if statements and more room for bugs to creep in.

The code so far can be played with in TypeScript playground.

The solution – discriminated unions

The solution for making the runtime "aware" of our compile time types is surprisingly simple. All we need is a tag for the types to be able to uniquely identify each variant, and discriminate based on that information. In practice this means adding a property with a literal value that can be used to identify each variant both at compile time and at runtime.

Note about the naming. The attribute can be called anything as long as the name is consistent between the variants. Here we use _t. Other commonly used names are type, tag, or kind, with or without an underscore. It could also be something more relevant to the domain that's being modelled, so in our case of animals we could use something like species.

// cats meow
type Cat = BaseAnimal & {
  _t: 'cat', // <- the discriminator for cat
  meow: () => string;
}

// dogs bark
type Dog = BaseAnimal & {
  _t: 'dog', // <- the discriminator for dog
  bark: () => string;
}

type Animal = Cat | Dog;
Enter fullscreen mode Exit fullscreen mode

Note that the value of _t needs to be unique for TypeScript to be able to uniquely identify each variant. Now the business logic becomes easier: all we need to do is check for the value of _t, and the TypeScript compiler knows to automatically narrow the type based on it. For example, and Animal with _t === 'dog' can be narrowed from Animal to Dog.

Note also the assertNever helper. Here it's used to make sure that all the different variants are handled in the branches of the switch statement.

const assertNever = (n: never): never => {
  throw new Error('Should never happen')
}

const makeNoise = (animal: Animal): string => {
  switch (animal._t) {
    case 'cat':
      return animal.meow();
    case 'dog':
      return animal.bark();
    default:
      return assertNever(animal);
  }
}
Enter fullscreen mode Exit fullscreen mode

The following screenshot illustrates how the compiler is able to narrow down from the generic Animal to the specific Cat and Dog based on the code branch we're in.

The compiler can narrow down the type based on the code branch

The solution code as a whole can be found here.

Practical example – async HTTP states

While the previous example was hopefully illustrative, we rarely write code about cats and dogs. A more useful and complete example could be the familiar situation in SPA applications fetching data: rendering the different states.

Our imaginary application starts with a clean state, then performs an asynchronous HTTP API call to fetch some data, and then renders the resulting success or error based on what happened with the request. Here's a minimal example:

type Data = {
  items: string[];
};

type State =
  | { _t: "initial" }
  | { _t: "loading" }
  | { _t: "error"; err: Error }
  | { _t: "success"; data: Data };
Enter fullscreen mode Exit fullscreen mode

As you can see, we can enumerate the different known states using discriminated unions. This way, when we write the rendering logic, we don't need to do any "if data then do something with data" type of checking. Instead we can just check for the value of _t, and the compiler knows to narrow it down based on that.

Note again that _t could be called anything else too, as long as the name is consistent between the variants. In this case another example of a sensible discriminator name could be status.

The example is in react, but could be done in any other rendering pattern as well.

const renderer = (state: State) => {
    switch (state._t) {
      case "initial":
        return <p>Click button to start</p>;
      case "loading":
        return <p>Loading...</p>;
      case "error":
        return (
          <div>
            <h3>Oops, error happened!</h3>
            <p>{state.err.message}</p>
          </div>
        );
      case "success":
        return (
          <div>
            <h3>Here's your data:</h3>
            <ol>
              {state.data.items.map((i) => (
                <li key={i}>{i}</li>
              ))}
            </ol>
          </div>
        );
    }
  }
Enter fullscreen mode Exit fullscreen mode

Here's the full working example in CodeSandbox: https://codesandbox.io/s/typescript-discriminate-unions-in-async-state-rendering-l2jbdl?file=/src/App.tsx

Other use cases

I have found discriminated unions especially helpful in situations where the result of an operation needs to be categorized into a success of a failure on a high level (hint: the Either monad is a super useful pattern), and the different variants of successes and/or failures categorized even deeper. So think something like:

type Failure = LogicFailure | DatabaseFailure;

type Success = SuccessWithData | SuccessWithoutData;

type Result = Failure | Success;
Enter fullscreen mode Exit fullscreen mode

You can see how the discriminated union pattern helps us compose the larger data types out of smaller enumerated pieces, and build the logic around what happens when each small piece is handled. This is useful for representing expected different outcomes of functions instead of throwing errors, and this is discussed more in a separate post of mine.

This is also very useful for the purpose of building observability into the different variants, for example categorizing what kinds of errors are encountered. More on that in a separate post later.

Top comments (0)