Ideas for making TypeScript better at testing types

[2025-04-12] dev, typescript
(Ad, please don’t block)

In this blog post, we examine how we can test types in TypeScript:

  • First, we look at the library asserttt and the CLI tool ts-expect-error.
  • Then, we consider which functionality could be built into TypeScript.

Why is testing types useful?  

  • Since we can program at the type level with generic types (type-level functions), conditional types, mapped types, etc., we also need a way to write tests at the type level.

  • Testing types helps with documenting features – with code that can be automatically tested via tools such as Markcheck. This is one of many examples in “Exploring TypeScript”. Even though that looks uglier than other ways of showing types in code examples, it has several benefits.

  • TypeScript exercises such as the ones provided by Type<Challenge[]> require a way to test types.

  • In normal code, it can help ensure that related types are consistent.

Testing types via libraries and external tools  

Testing types  

import { type Assert, type Equal } from 'asserttt';

type UnionOf<Tup extends ReadonlyArray<unknown>> = Tup[number];

// Test: Does `UnionOf` turn a tuple into a union?
type _ = Assert<Equal<
  UnionOf<['rose', 'sunflower', 'lavender']>,
  'rose' | 'sunflower' | 'lavender'
>>;

Explanations:

  • We are using the library asserttt.
  • The generic type Assert<B> fails if its parameter B is not true.
  • The generic type Equal<X, Y> returns true if its parameters X and Y are equal and false, otherwise.

Testing failures  

The following code demonstrates the effects of the compiler option noImplicitAny:

// @ts-expect-error: Parameter 'name' implicitly has an 'any' type.
function hello(name) {
  return `Hello ${name}!`
}

By default, TypeScript only warns us if there is no error in the line after @ts-expect-error. The tool ts-expect-error additionally warns us if the error message does not match what comes after the colon.

What built-in support could TypeScript offer?  

Built-in utility types  

asserttt provides the following utility types:

  • Asserting:
    • Assert<B> (compiler error if B isn’t true)
    • assertType<T>(value)
  • Predicates for equality (boolean results):
    • Equal<X, Y>
    • MutuallyAssignable<X, Y>
    • PedanticEqual<X, Y>
  • Comparing/detecting types:
    • Extends<Sub, Super>
    • Assignable<Target, Source>
    • Includes<Superset, Subset>
    • IsAny<T>
  • Boolean operations:
    • Not<B>

Those utility types work reasonably well as a library. But it may make sense to build some of them into TypeScript.

The only types that are cumbersome to use are:

  • Assert<B> is verbose and affected by name clashes (see next subsection).
  • assertType<T>(value) only checks if value is assignable to T not if its type is equal to T. That makes it less useful for testing.

Better syntax for type-level assertions  

There are two ways in which we can use Assert:

// Single type-level assertion
type _ = Assert<Equal<T1, T2>>;

// Multiple type-level assertions
type _2 = [ // (A)
  Assert<Equal<T1, T2>>,
  Assert<Extends<T1, T2>>,
];

These patterns have two downsides:

  • They are verbose.
  • We can only use the name _ once (as you can see in line A where we had to use the name _2).

Built-in support?  

Name clashes could be avoided by:

  • Allowing the type name _ to be used multiple times.
  • Allowing anonymous type aliases:
    type = Assert<Equal<T1, T2>>; // require `void` or `void[]`?
    

However, support could go even further and help reduce verbosity:

type! Equal<T1, T2>; // result must be `true`
type test Equal<T1, T2>; // result must be `true`

Another option is type assert, but given that TypeScript already uses assert for type guards and assertion functions, that could be problematic.

Support for failing at the type level  

Sometimes when writing code that runs at the type level, it would be useful if we could fail at compile time. Currently, we can only do that via extends constraints for type parameters. More explicit support could look like this:

// Doesn’t work: Assert requires its argument to be `true`
type AssertEqual<T1, T2> = Assert<Equal<T1, T2>>;

// Maybe:
type AssertEqual<T1, T2> = Equal<T1, T2> extends true ? true : fail;

Many utility types return never when fail would have been a better option – e.g.:

type First<T extends Array<unknown>> =
T extends [infer F, ...unknown[]]
  ? F
  : never
;

More checks for @ts-expect-error  

The functionality provided by the CLI tool ts-expect-error could be built into TypeScript. The question is how do we check if an error message is as expected?

  • We can check the error message.
    • Downside: Error messages evolve over time.
  • We can check the error code.
    • Downside: That makes it difficult to evolve errors (split them, change their semantics, etc.).
    • Downside: Humans don’t immediately see what an error code means.

ts-expect-error checks if the provided text is a prefix of the actual error message. That has worked well for me: If an error message changes, I simply update it in my tests.

Related GitHub issue: “Allow specifying diagnostic code in ts-expect-error”

Further reading