Search

Testing Types in TypeScript

Say that 10 times fast.

As your TypeScript usage gets more advanced, it can be extremely helpful to have utilities around that test and verify your types. Like unit testing, but without needing to set up Jest, deal with mocking, etc. In this post, we’ll introduce this idea. Then we’ll dive deeply into one particular testing utility that’s surprisingly difficult to create: a type that checks whether two types are the same.

This post will cover some advanced corners of TypeScript you’re unlikely to need for regular application code. If you’re not a huge fan of TS, please understand that you probably won’t need the things in this post for your everyday work, which is fine. But if you’re writing or maintaining TypeScript libraries, or even library-like code in your team’s app, the things we discuss here might come in handy.

Type helpers

Consider this Expect helper:

type Expect<T extends true> = T;Code language: TypeScript (typescript)

This type demands you pass true into it. This seems silly, but stay with me.

Now imagine, for some reason, you have a helper for figuring out whether a type is some kind of array:

type IsArray<T> = T extends Array<any> ? true : false;Code language: TypeScript (typescript)

You’d like to verify that IsArray works properly. You could type:

type X = IsArray<number[]>;Code language: TypeScript (typescript)

Then mouse over the X and verify that it’s true, which it is. But we don’t settle for ad hoc testing like that with normal code, so why would we with our advanced types?

Why don’t we write this instead:

type X = Expect<IsArray<number[]>>;Code language: TypeScript (typescript)

If we had messed up our IsArray type, the line above would error out, which we can see by passing the wrong thing into it:

type Y = Expect<IsArray<number>>;
// error: Type 'false' does not satisfy the constraint 'true'.Code language: TypeScript (typescript)

Better yet, let’s create another helper:

type Not<T extends false> = true;Code language: TypeScript (typescript)

and now we can actually test the negative of our IsArray helper

type Y = Expect<Not<IsArray<number>>>;Code language: TypeScript (typescript)

Except these fake type names like X and Y will get annoying very quickly, so let’s do this instead

// ts-ignore just to ignore the unused warning - everything inside of Tests will still type check
// @ts-ignore
type Tests = [
  Expect<IsArray<number[]>>,
  Expect<Not<IsArray<number>>>
];Code language: TypeScript (typescript)

So far so good. We can put these tests for our types right in our application files if we want, or move them to a separate file; the types are all erased when we ship either way, so don’t worry about bundle size.

Getting serious

Our IsArray type was trivial, as were our tests. In real life, we’ll be writing types that do more interesting things, usually taking in one or more types, and creating something new. And to test those sorts of things, we’ll need to be able to verify that two types are identical.

For example, say you want to write a type that takes in a generic, and if that generic is a function, returns the parameters of that function, else returns never. Pretend the Parameters type is not built into TypeScript, and imagine you write this:

type ParametersOf<T> = T extends (...args: infer U) => any ? U : never;Code language: TypeScript (typescript)

Which we’d test like this:

// ts-ignore just to ignore the unused warning - everything inside of Tests will still type check
// @ts-ignore
type Tests = [
  Expect<TypesMatch<ParametersOf<(a: string) => void>, [string]>>,
  Expect<TypesMatch<ParametersOf<string>, never>>
];Code language: TypeScript (typescript)

Great. But how do you write TypesMatch?

That’s the subject of the entire rest of this post. Buckle up!

Type Equality

Checking type equality is surprisingly hard in TypeScript, and the obvious solution will fail for baffling reasons unless you understand conditional types. Let’s tackle that before moving on.

We’ll start with the most obvious, potential solution:

type TypesMatch<T, U> = T extends U ? true : false;Code language: TypeScript (typescript)

You can think of T extends U in the same way as with object-oriented inheritance: is T the same as, or a sub-type of U. And instead of (just) object-oriented hierarchies, remember that you can have literal types in TypeScript. type Foo = "foo" is a perfectly valid type in TypeScript. It’s the type that represents all strings that match "foo". Similarly, type Foo = "foo" | "bar"; is the type representing all strings that match either "foo", or "bar". And literal types, and unions of literal types like that can both be thought of as sub-types of string, for these purposes.

Another (more common way) to think about this is that T extends U is true if T can be assigned to U, which makes sense; if T is the same, or a sub-type of U, then a variable of type T can be assigned to a variable of type U.

The obvious test works:

type Tests = [Expect<TypesMatch<string, string>>];Code language: TypeScript (typescript)

So far, so good. And:

type Tests = [Expect<Not<TypesMatch<string, "foo">>>];Code language: TypeScript (typescript)

This also works, since a variable of type string cannot be assigned to a variable of type "foo".

let foo: "foo" = "foo";
let str: string = "blah blah blah";

foo = str; // Error
// Type 'string' is not assignable to type '"foo"'.Code language: TypeScript (typescript)

But we hit problems with:

type Tests = [Expect<Not<TypesMatch<"foo", string>>>];Code language: TypeScript (typescript)

This fails. The string literal type "foo" is assignable to variables of type string.

Just test them both ways

I know what you’re thinking: just test it from both directions!

type TypesMatch<T, U> = T extends U
  ? U extends T
    ? true
    : false
  : false;Code language: TypeScript (typescript)

This solves both of our problems from above. Now both of these tests pass.

type Tests = [
  Expect<Not<TypesMatch<string, "foo">>>,
  Expect<Not<TypesMatch<"foo", string>>>,
];Code language: TypeScript (typescript)

Let’s try union types:

type Tests = [
  Expect<TypesMatch<string | number, string | number>>,
  Expect<Not<TypesMatch<string | number, string | number | boolean>>>
];Code language: TypeScript (typescript)

Both of these fail with:

Type ‘boolean’ does not satisfy the constraint ‘true’

Identical union types fail to match as identical, and different union types fail to match as different. What in the world is happening.

Conditional types over unions

So why did this not work with union types?

type TypesMatch<T, U> = T extends U
  ? U extends T
    ? true
    : false
  : false;Code language: TypeScript (typescript)

Let’s back up and try to simplify. Let’s imagine some simple (useless) types. Imagine a square and circle:

type Square = {
  length: number;
};
type Circle = {
  radius: number;
};Code language: TypeScript (typescript)

Now imagine a type that takes a generic in, and returns a description. If we pass in a Square, it returns the string literal type "4 Sides". If we pass in a circle, it returns the string literal "Round". And if we pass in anything else, it returns the string literal type "Dunno". This is very silly but just go with it.

type Description<T> = T extends Square
  ? "4 Sides"
  : T extends Circle
  ? "Round"
  : "Dunno";Code language: TypeScript (typescript)

Now imagine a function to use this type:

function getDescription<T>(obj: T): Description<T> {
  return null as any;
}
const s: Square = { length: 1 };
const c: Circle = { radius: 1 };

const sDescription = getDescription(s);
const cDescription = getDescription(c);Code language: TypeScript (typescript)

sDescription is of type "4 Sides" and cDescription is of type "Round". Nothing surprising. Now let’s consider a union type.

const either: Circle | Square = {} as any;
const eitherDescription = getDescription(either);Code language: TypeScript (typescript)

The type Circle | Square does not extend Square (a variable of type Circle | Square cannot be assigned to a variable of type Square) nor does it extend Circle. So we might naively expect eitherDescription to be "Dunno". But intuitively this feels wrong. either is a Circle or a Square, so the description should be either "4 Sides" or "Round".

And that’s exactly what it is:

Union Type

Distributing union types

When we have a generic type argument that’s also a type union, pushed across an extends check in a conditional type, the union itself is split up with each member of the union substituted into that check. TypeScript then takes every result, and unions them together. That union is the result of that extends operation.

Any never‘s are removed, as are any duplicates.

So for the above, we start with our type:

We substitute the union type that we passed in for T

Once our conditional type hits the extends keyword, if we passed in a union type, TypeScript will distribute over the union; it’ll run that ternary for each type in our union, and then union all of those results together. Square is first:

Square extends Square so "4 Sides" is the result. Then repeat with Circle:

Circle extends Circle so "Round" is the result of the second iteration. The two are union’d together, resulting in the "4 Sides" | "Round" that we just saw.

Playing on Hard Mode

Let’s take a fresh look at this:

type TypesMatch<T, U> = T extends U
  ? U extends T
    ? true
    : false
  : false;Code language: TypeScript (typescript)

Here’s what happens with:

TypesMatch<string | number, string | number>;Code language: TypeScript (typescript)

We start:

Then substitute string | number in for T and U. Evaluation starts, and immediately gets to our first extends:

T and U are both unions, but only the type before the extends is distributed. Let’s substitute string | number for U:

Now we’re ready to process that first extends. We’ll need to break up the T union, and run it for every type in that union. string is first:

string does extends string | number so we hit the true branch, and are immediately greeted by a second extends.

Think of it like a nested loop. We’ll process this inner extends in the same way. We’ll substitute each type in the U union, starting with string:

and of course string extends string is true. Our first result is true.

Now let’s continue processing our inner loop. U will next be numbernumber extends string is of course false, which is the second result of our type.

So far we have true | false. Now our outer loop continues. T moves on to become the second member of its union, numbernumber extends string | number is true:

so we again hit that first branch:

The inner loop starts all over again, with U first becoming string:

string extends number is false, so our overall result is true | false | falseU then becomes number:

which yields true.

The whole thing produced true | false | false | true. TypeScript removes the duplicates, leaving us with true | false.

Do you know what a simpler name for the type true | false is? It’s boolean. This type produced boolean, which is why we got the error:

Type ‘boolean’ does not satisfy the constraint ‘true’

We were expecting a literal type of true but got a union of true and false, which reduced to boolean.

It’s the same idea with:

type Result = TypesMatch<string | number, string | number | boolean>;Code language: TypeScript (typescript)

We won’t go through all the permutations. Suffice it to say we’ll get some mix of true and false, which will reduce back to boolean again.

So how to fix it?

We need to stop the union types from distributing. The distributing happens only with a raw generic type in a conditional type expression. We can turn it off by turning that type into another type, with the same assignability rules. A tuple type does that perfectly:

type TypesMatch<T, U> = [T] extends [U]
  ? [U] extends [T]
    ? true
    : false
  : false;Code language: TypeScript (typescript)

Instead of asking if T extends U I ask if [T] extends [U][T] is a tuple type with one member, T. Think of it with non-generic types. [string] is essentially an array with a single element (and no more!) that’s a string.

All our normal rules apply. ["foo"] extends [string] is true, while [string] extends ["foo"] is false. You can assign a tuple with a single "foo" string literal to a tuple with a single string, for the same reason that you can assign a "foo" string literal to a variable of type string.

One last fix

We’re pretty much done, don’t worry.

type TypesMatch<T, U> = [T] extends [U]
  ? [U] extends [T]
    ? true
    : false
  : false;

type Tests = [
  Expect<Not<TypesMatch<string, "foo">>>,
  Expect<TypesMatch<string | number, string | number>>,
  Expect<Not<TypesMatch<string | number, string | number | object>>>
];Code language: TypeScript (typescript)

This mostly works, but there’s one rub: optional properties. This test currently fails

Expect<{ a: number }, { a: number; b?: string }>;Code language: TypeScript (typescript)

Unfortunately both of those types are assignable to each other, which makes sense. The b in the second type is optional. { a: number; b?: string } can in fact be assigned to a variable expecting { a: number }, since the structure of { a: number } is satisfied. TypeScript is happy to ignore the extra properties. We’re really close though. This only happens if either type has optional properties which are absent from the other type.

This already fails:

Expect<{ a: number; b?: number }, { a: number; b?: string }>;Code language: TypeScript (typescript)

Those types are not at all compatible. So why don’t we just keep what we already have and verify that all of the property keys are the same. We’ll rename a smidge and wind up with this:

type ShapesMatch<T, U> = [T] extends [U]
  ? [U] extends [T]
    ? true
    : false
  : false;

type TypesMatch<T, U> = ShapesMatch<T, U> extends true
  ? ShapesMatch<keyof T, keyof U> extends true
    ? true
    : false
  : false;Code language: TypeScript (typescript)

What was TypesMatch is now ShapesMatch and our real TypesMatch calls that, then calls it again on the types’ keys. Don’t be scared of keyof T — that’s safe. This will work on primitive types, function types, etc. So long as the types are the same, the result will be the same. If the types are object types, they’ll match up those object properties.

Wrapping up

Conditional types can be incredibly helpful when you’re building advanced types for testing or otherwise. But they also come with some behaviors that can be surprising to the uninitiated. Hopefully this post made some of that clearer.

Wanna learn TypeScript deeply?

Frontend Masters logo

We have a complete learning path for TypeScript from Mike North, one of Stripe's deepest TypeScript experts, who will teach you from early fundamentals to production-grade TypeScript.

Leave a Reply

Your email address will not be published. Required fields are marked *