Testing types in TypeScript

[2025-02-24] dev, typescript
(Ad, please don’t block)

In this blog post, we explore how we can test that complicated TypeScript types work as expected. To do that, we need assertions at the type level and other tools.

Asserting at the type level  

Writing more complicated types is like programming at a different level:

  • At the program level, we use JavaScript – e.g.:
    • Values
    • Functions with parameters
  • At the type level, we use (non-JavaScript) TypeScript – e.g.:
    • Types
    • Generic types with type parameters

At the program level, we can use assertions such as assert.deepEqual() to test our code:

const pair = (x) => [x, x];
const result = pair('abc');
assert.deepEqual(
  result, ['abc', 'abc']
);

So how can we test type-level code – which is important for complicated types? We also need assertions – e.g.:

type Pair<T> = [T, T];
type Result = Pair<'abc'>;
type _ = Assert<Equal<
  Result, ['abc', 'abc']
>>;

The generic types Assert and Equal are part of my npm package asserttt. In this blog post, we’ll use this package in two ways:

  • On one hand, we reimplement its API to see how it works.
  • On the other hand, we use its API to check that what we have implemented works as desired.

To avoid confusion, I’ll use different names in our version of asserttt.

How to check if two types are equal?  

The most important part of a type-level assertion API is checking whether two types are equal. As it turns out, that is surprisingly difficult.

A naive solution  

A naive solution works as follows. Two types X and Y are equal if both:

  • X extends Y (X is a subtype of Y).
  • Y extends X.

We’d think that that is only the case if X and Y are equal (which is almost true, as we’ll see soon).

Let’s implement this approach via a generic type SimpleEqual1:

type SimpleEqual1<X, Y> =
  X extends Y
    ? (Y extends X ? true : false)
    : false
;
type _ = [
  Assert<Equal<
    SimpleEqual1<'hello', 'hello'>, // (A)
    true
  >>,
  Assert<Equal<
    SimpleEqual1<'yes', 'no'>, // (B)
    false
  >>,
  Assert<Equal<
    SimpleEqual1<string, 'yes'>, // (C)
    false
  >>,
  Assert<Equal<
    SimpleEqual1<'a', 'a'|'b'>, // (D)
    true | false
  >>,
];

The test cases start off well: line A and line B produce the expected results and even the more tricky check in line C works correctly.

Alas, in line D, the result is both true and false. Why is that? SimpleEqual1 is defined using conditional types and those are distributive over union types (more information).

Disabling distribution  

In order for SimpleEqual to work as desired, we need to switch off distribution. A conditional type is only distributive if the left-hand side of extends is a bare type variable (more information). Therefore, we can disable distribution by turning both sides of extends into single-element tuples:

type SimpleEqual2<X, Y> =
  [X] extends [Y]
    ? ([Y] extends [X] ? true : false)
    : false
;
type _ = [
  Assert<Equal<
    SimpleEqual2<'hello', 'hello'>,
    true
  >>,
  Assert<Equal<
    SimpleEqual2<'yes', 'no'>,
    false
  >>,
  Assert<Equal<
    SimpleEqual2<string, 'yes'>,
    false
  >>,
  Assert<Equal<
    SimpleEqual2<'a', 'a'|'b'>, // (A)
    false
  >>,
  Assert<Equal<
    SimpleEqual2<any, 123>, // (B)
    true
  >>,
];

Now we can also handle union types correctly (line A). However, one problem remains (line B): any is equal to any other type. That means we can’t really check if a given type is any. And we can’t check that a type is not any because if it is, it’ll be equal to anything we compare it too.

Strictly comparing any  

There is no straightforward way of implementing an equality check in a way that distinguishes any from other types. Therefore, we have to resort to a hack:

type StrictEqual<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends // (A)
  (<T>() => T extends Y ? 1 : 2) ? true : false // (B)
;
type _ = [
  Assert<Equal<
    StrictEqual<'hello', 'hello'>,
    true
  >>,
  Assert<Equal<
    StrictEqual<'yes', 'no'>,
    false
  >>,
  Assert<Equal<
    StrictEqual<string, 'yes'>,
    false
  >>,
  Assert<Equal<
    StrictEqual<'a', 'a'|'b'>,
    false
  >>,
  Assert<Equal<
    StrictEqual<any, 123>, // (C)
    false
  >>,
  Assert<Equal<
    StrictEqual<any, any>, // (D)
    true
  >>,
];

This hack was suggested by Matt McCutchen (source). And does indeed what we want (line C and line D). But how does it work (source)?

In order to check whether the function type in line A extends the function type in line B, TypeScript has to compare the following two conditional types:

T extends X ? 1 : 2
T extends Y ? 1 : 2

Since T does not have a value, both conditional types are deferred. Assignability of two deferred conditional types is computed via the internal function isTypeIdenticalTo() and only true if:

  1. Both have the same constraint.
  2. Their true branches have the same type and their false branches have the same type.

Thanks to #1, X and Y are compared precisely.

How do we assert that something must be true?  

At the program/JavaScript level, we can throw an exception if an assertion fails:

function assert(condition) {
  if (condition === false) {
    throw new Error('Assertion failed');
  }
}
function equal(x, y) {
  return x === y;
}

assert(equal(3, 4)); // throws an exception

Alas, there is no way to fail at compile time in TypeScript. If there were, it could look like this:

type AssertType1<B extends boolean> = B extends true ? void : Fail;

This type has the same result as a void function if B is true. If it is false then it fails at the type level, via the invented pseudo-type Fail.

The closest thing to Fail that TypeScript currently has, is never. However, never does not cause type checking errors.

Thankfully, there is a decent workaround that mostly gives us what we want:

type AssertType2<_B extends true> = void; // (A)
type _ = [
  AssertType2<true>, // OK
  // @ts-expect-error: Type 'false' does not satisfy
  // the constraint 'true'.
  AssertType2<false>,
];

When we pass arguments to a generic type, the extends constraints of all of its parameters must be fulfilled. Otherwise, we get a type-level failure. That’s what we use in line A: The value we pass to AssertType2 must be true or the type checker complains.

This workaround has limits, though. The following functionality can only be implemented via Fail:

type AssertEqual<X, Y> = true extends Equal<X, Y> ? void : Fail;

Not: utility type for boolean negation  

Switching back to asserttt names – once we have Equal and Assert, we can implement more helper types – e.g. Not<B>:

type Not<B extends boolean> = B extends true ? false : true;

Not enables us to assert that one type is not equal to another type:

type _ = Assert<Not<Equal<
  'yes', 'no'
>>>;

A generic type whose result is true or false is called a predicate. Such types can be used with Assert. Equal and Not are predicates. But more predicates are conceivable and useful – e.g.:

/**
 * Is type `Target` assignable from type `Source`?
 */
type Assignable<Target, Source> = Source extends Target ? true : false;

type _ = [
  Assert<Assignable<number, 123>>,
  Assert<Assignable<123, 123>>,

  Assert<Not<Assignable<123, number>>>,
  Assert<Not<Assignable<number, 'abc'>>>,
];

asserttt defines more predicates.

Asserting errors  

Sometimes, we need to test that an error happens where we expect it. At the JavaScript level, we can use functions such as assert.throws():

assert.throws(
  () => null.prop,
  {
    name: 'TypeError',
    message: "Cannot read properties of null (reading 'prop')",
  }
);

At the type level, we can use @ts-expect-error:

// @ts-expect-error: The value 'null' cannot be used here.
null.prop;

By default, @ts-expect-error only checks that an error exists not which error it is. To check the latter, we can use a tool such as the npm package ts-expect-error.

A more complex example  

For fun, let’s compare another JavaScript-level test with its analog at the type level. This is the JavaScript code:

function upperCase(str) {
  if (typeof str !== str) {
    throw new TypeError('Not a string: ' + str);
  }
  return str.toUpperCase();
}
assert.throws(
  () => upperCase(123),
  {
    name: 'TypeError',
    message: 'Not a string: 123',
  }
);

For the type-level code, I’m omitting the runtime type check – even though that can often still make sense.

function upperCase(str: string) {
  return str.toUpperCase();
}

// @ts-expect-error: Argument of type 'number' is not assignable to
// parameter of type 'string'.
upperCase(123);

Asserting the type of a value  

When testing types, we also may want to check if a value has a given type – as provided via inference or a generic type. One way of doing so is via the typeof operator (line A):

const pair = <T>(x: T): [T, T] => [x, x];
const value = pair('a' as const);
type _ = Assert<Equal<
  typeof value, ['a', 'a'] // (A)
>>;

Another option is via a helper function assertType():

assertType<['a', 'a']>(value);

Using a program-level function makes this check less verbose because we can directly accept program-level values, we don’t have to convert them to type-level values via typeof. This is what assertType() looks like:

function assertType<T>(_value: T): void { }

We don’t do anything with the parameter _value; we only statically check if it is assignable to the type parameter T. One limitation of assertType() is that it only checks assignability; it does not check type equality. For example, we can’t check that a value has the type string and not a more specific type:

const value_string: string = 'abc';
const value_abc: 'abc' = 'abc';

assertType<string>(value_string);
assertType<string>(value_abc);

In line A, the type of value_abc is assignable to string but it is not equal to string.

In contrast, Equal enables us to check that a value does not have a type that is more specific than string:

type _ = [
  Assert<Equal<
    typeof value_string, string
  >>,
  Assert<Not<Equal<
    typeof value_abc, string
  >>>,
];

A use case for a type-level assertion in normal code  

Occasionally, type-level assertions are even useful in normal (non-test) code. For example, let’s assume we both have a type Person and an Array personKeys with the keys of that type:

interface Person {
  first: string;
  last: string;
}

const personKeys = [
  'first',
  'last',
] as const;

If we want the compiler to warn us if we get personKeys wrong, we can use a type-level assertion:

type _1 = Assert<Equal<
  keyof Person,
  (typeof personKeys)[number] // (A)
>>;

In line A, we use the indexed access operator T[K] to convert the Array to a union of the types of its elements (more information on this technique):

type _2 = [
  Assert<Equal<
    (typeof personKeys)[number],
    'first' | 'last'
  >>,
  Assert<Equal<
    keyof Person,
    'first' | 'last'
  >>,
];

Running type-level tests  

To run normal tests written in TypeScript, we run the transpiled JavaScript. If tests include type-level assertions, we need to additionally type check them. Two options are:

  • First run the JavaScript. Then type-check the tests via tsc.
  • Run the tests via a tool such as tsx that type checks code before running it.

Further reading