Use TypeScript Assertion Functions for Legible Test Expectations

Here’s a technique to use TypeScript Assertion Functions to clean up test expectations around Discriminated Unions.

Three Ways to Assert

Let’s say we have a function foo that returns a Discriminated Union like this Result type:


export type Ok<T> = { outcome: "ok"; value: T };
export type Err = { outcome: "error"; error: string };
type Result<T> = Ok<T> | Err;

function foo(input: number): Result{ /* ... */ }

We want to write some unit tests for foo. It’s pretty straightforward when the subject’s behavior is deterministic:


Deno.test("returns an error for odd input", () => {
  const result = foo(3);
  expect(result).toEqual({
    outcome: "error",
    error: "input was odd",
  });
});

However, it gets messier when the output is unpredictable:


Deno.test("increases an even number", () => {
  const result = foo(2);

  expect(result.outcome).toEqual("ok");

  // deno-lint-ignore no-explicit-any
  expect((result as any).value).toBeGreaterThan(2);

  // or, alternatively:
  expect(result.outcome === "ok" && result.value > 2);
});

The problem is that TypeScript doesn’t know the type of result, so we can’t safely access value.

TypeScript offers a tool for this: user-defined Type Guards. We can write one to prove the type of Result:


function isOk<T>(input: Result<T>): input is Ok<T> {
  return input.outcome === "ok";
}

These things are great in application code, but they don’t help tremendously with our test assertions:


Deno.test("uses a type guard", () => {
  const result = foo(2);

  if (isOk(result)) {
    // the Type Guard helps with our happy path:
    expect(result.value).toBeGreaterThan(2);
    // but it creates this awkward "else" case:
  } else {
    assert(false, "expected OK result, got error");
  }
});

Instead we can use a related TypeScript feature called Assertion Functions:


function expectOk<T>(input: Result<T>): asserts input is Ok<T> {
  expect(input.outcome).toEqual("ok");
}

Assertion Functions are like Type Guards, but for functions that throw (or exit). If expectOk returns, then we know input is an Ok<T>. This lines up nicely with the behavior of our test helper: the test framework stops at the first failed expect().

With this, our test gets much cleaner:


Deno.test("uses assertion function", () => {
  const result = foo(2);

  expectOk(result);

  expect(result.value).toBeGreaterThan(2);
});

Closing Thoughts on Assertion Functions

Tests should be as legible as possible, and this technique helps ours stay that way. Have you used any TypeScript language features to keep your tests short and sweet?

Sample code from this post is on GitHub.

Related Reading:

Footnote: I used this as an excuse to try Deno, and so far I’m impressed. Everything I needed came out of the box: TypeScript, a test runner, a linter and a formatter, with nary a package.json or yarn install to be found.