Testing the Types Themselves

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Runtime tests ask, "what did this code do?" Type tests ask, "what does TypeScript believe this API is?" For app code, a few type tests protect tricky helpers and public boundaries. For libraries, they protect the product itself: the types your users import.

This lesson has one live pure-TypeScript playground for tiny type assertions. Vitest's expectTypeOf and assertType examples are read-only reference code because Vitest is not installed in the playground.

A tiny type assertion toolkit

A type-level test is just a type that should compile. If the type assertion becomes false, the compiler reports an error before anything runs.

Here is the smallest version worth understanding:

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

→ Mina

Equal<A, B> compares two types. Expect<T extends true> accepts only true. Put them together and a type alias becomes a compile-time assertion.

The important part is not the clever helper. It is the workflow: name the public shape you care about, assert it stays what callers rely on, and let the compiler fail when a refactor changes it. The commented line asks for an impossible shape, so the test fails before runtime.

Keep helpers like this shallow. Type-level tests can get expensive if you build recursive puzzles inside them. The point is to protect API facts, not to impress the checker.

expectTypeOf and assertType in Vitest

Vitest gives you a nicer API for the same idea. In real projects, type tests commonly live in *.test-d.ts files and are statically checked rather than executed like ordinary runtime tests.

Read-only reference code:

import { assertType, expectTypeOf } from "vitest";
import {
  createClient,
  toPublicUser,
  type ApiClient,
  type PublicUser,
  type User,
} from "./public-api";
 
const user: User = {
  id: "user_1",
  email: "mina@example.com",
  name: "Mina",
  plan: "pro",
};
 
expectTypeOf(toPublicUser).returns.toEqualTypeOf<PublicUser>();
expectTypeOf(toPublicUser).parameter(0).toEqualTypeOf<User>();
 
const client = createClient({ baseUrl: "https://api.example.test" });
 
assertType<ApiClient>(client);
expectTypeOf(client.getUser).returns.resolves.toEqualTypeOf<PublicUser>();

expectTypeOf reads like normal test syntax, but it is checking TypeScript relationships. Use expectTypeOf(...).toEqualTypeOf<T>() for exact equality. assertType<T>(value) is the short form when you want to say "this expression must be assignable to this public type."

These are not replacements for runtime tests. A type test can prove client.getUser returns Promise<PublicUser> according to TypeScript. It cannot prove the server sent a valid user, that the parser ran, or that the promise fulfilled. Sections 13 and 14 still own the runtime boundary.

Public type snapshots

When a package has users, its public types become a compatibility promise. A library may snapshot that promise with type tests around the exported surface:

  • Function parameters that must remain callable.
  • Return types that should preserve literal unions or generics.
  • Builder APIs that must infer the caller's shape.
  • Errors that must remain impossible for unsupported calls.

Read-only reference code:

import { expectTypeOf } from "vitest";
import {
  defineRoutes,
  type ClientForRoutes,
} from "./client";
 
const routes = defineRoutes({
  getUser: "/users/:id",
  listLessons: "/lessons",
});
 
type Client = ClientForRoutes<typeof routes>;
 
expectTypeOf<Client["getUser"]>().parameter(0).toEqualTypeOf<{
  id: string;
}>();
 
expectTypeOf<Client["getUser"]>()
  .returns
  .resolves
  .toMatchObjectType<{
    id: string;
    name: string;
  }>();
 
expectTypeOf<Client["listLessons"]>().parameter(0).toEqualTypeOf<void>();

This kind of file is a public-type snapshot. It does not store a screenshot or a JSON blob. It stores compile-time expectations for the API your users write against.

The library reason is obvious: users can break on a type-only change. The app-code reason is smaller but still real: type tests are useful around generic helpers, builder functions, route maps, schema inference, and any function whose type behavior is more important than its runtime body.

Negative type tests with an expected error

Some type tests should fail. A route client should reject missing params. A branded id constructor should not accept an arbitrary string after the brand has been applied. A public function should not accept an option your API does not support.

TypeScript's @ts-expect-error directive is the standard way to write that negative test: the next line must produce a type error. If the line stops erroring later, TypeScript reports that the directive is unused.

Read-only reference code:

import { expectTypeOf } from "vitest";
import { defineRoutes, type ClientForRoutes } from "./client";
 
const routes = defineRoutes({
  getUser: "/users/:id",
});
 
type Client = ClientForRoutes<typeof routes>;
 
declare const client: Client;
 
client.getUser({ id: "user_1" });
 
// @ts-expect-error - getUser requires an id param
client.getUser({});
 
// @ts-expect-error - unknown route methods are not part of this client
client.deleteUser({ id: "user_1" });
 
expectTypeOf(client.getUser).parameter(0).toEqualTypeOf<{
  id: string;
}>();

The comments are doing real work. If getUser({}) ever becomes accepted, the negative test fails because the expected error disappeared. That catches accidental widening: the kind of regression that runtime tests rarely notice because nothing has run yet.

Use @ts-expect-error, not @ts-ignore, for type tests. @ts-ignore suppresses an error whether or not it exists. @ts-expect-error records the expectation and fails when that expectation becomes stale.

Put type checks in CI

Section 16 now has three gates:

npm run typecheck
npm run test:run
vitest --typecheck

Your exact script names may differ. The important split is stable:

  • tsc --noEmit checks the full project.
  • Runtime tests execute behavior.
  • Type tests lock the public type contracts you intentionally wrote down.

Do not write type tests for every variable. Most code is already covered by the compiler simply by being part of the program. Spend type tests where the inferred shape is the feature: exported library APIs, generic helpers, route/client builders, schema-derived types, typed factories, and negative cases that must remain rejected.

Section 17 turns from tests to code quality tooling. You will add lint rules that catch typed mistakes before review, including async mistakes a normal unit test may never schedule.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion