Type-level tests with expectTypeOf
Vitest's expectTypeOf and the --typecheck pass let you write tests the compiler checks, pinning unions, branded IDs, and generic inference that runtime tests never see.
Suppose a teammate opens a pull request that “simplifies” an ID alias: type InvoiceId = Brand<string, 'InvoiceId'> becomes type InvoiceId = string.
The whole suite stays green, so they merge.
Three weeks later a function that expected an invoice’s ID is handed a user’s ID, and nothing stops it.
The guarantee that those two IDs could never be confused is gone, and every runtime test passed the whole time, because none of them was looking at types.
The last three lessons proved values with expect(fn(input)).toEqual(output), run by Vitest.
But a codebase has a second correctness surface the runner never touches, its types: a union’s members, a function’s signature, a brand’s distinctness, the shape of a Result.
These are compile-time guarantees, invisible to a test that only calls functions and inspects return values.
Widen a union, drop a brand, or let a generic collapse to unknown, and the contract breaks while every runtime test stays green.
The fix is a test you write for the compiler.
By the end of this lesson you’ll write a *.test-d.ts file, run it under vitest --typecheck, and pick the matcher that pins a union, a brand, a Result shape, or a generic’s inference, so the next unbranded ID goes red before it merges.
Two surfaces: values and types
Section titled “Two surfaces: values and types”Here is the opening regression: a /lib file exports a branded ID, and the “simplification” drops the brand.
export type InvoiceId = Brand<string, 'InvoiceId'>;Branded. The Brand wrapper makes InvoiceId a distinct type, so a UserId or a bare string can’t be passed where an InvoiceId is expected.
export type InvoiceId = string;Unbranded. Now InvoiceId is a plain alias for string. Every value that was distinct is interchangeable, and nothing in the runtime suite can tell.
The only test touching this file is a runtime test that calls a function returning an InvoiceId and checks the string came back:
it('returns the underlying string', () => { expect(toInvoiceId('inv_42')).toBe('inv_42');});This test passes before the change and after, because the value, the string itself, is identical either way. The brand lived purely in the type, and the runner erases types before it runs a line, so the thing that broke is invisible to it.
A type-level test asserts on the type, checked by tsc rather than by running code:
expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();Read that as a sentence: “an InvoiceId is not the same type as a plain string.”
Before the refactor it holds, so the checker stays quiet.
After, InvoiceId is string, the assertion is false, and the typecheck pass reports it as a failed test, catching the drift at the boundary where it happened.
Runtime tests assert values; type tests assert types; neither checker sees the other’s surface.
From here on it’s mechanics: a different matcher family and a different file suffix.
Where type tests live and how they run
Section titled “Where type tests live and how they run”Type tests sit beside their runtime siblings, a third file next to result.ts and result.test.ts:
Directorysrc/
Directorylib/
- result.ts the unit
- result.test.ts runtime tests, run by Vitest
- result.test-d.ts type tests, type-checked and never run
The .test-d.ts suffix is the signal: the runtime runner skips these files, and the typecheck pass picks them up.
The split is deliberate, since type-checking is slower than running values and you don’t want it firing on every keystroke alongside your fast unit tests.
You run them with a flag:
vitest --typecheckThis runs tsc --noEmit over the type-test files and reports every type error as a failed Vitest test, so a broken type contract shows up in the same red output, in the same suite, as a broken value.
In CI you run it once, with no watch:
vitest run --typecheckNarrow the typecheck pass to the *.test-d.ts glob so the rest of your suite isn’t dragged through tsc a second time.
This is the only addition; the unit project, globals: false, and the test glob are already in place from earlier:
test: { typecheck: { include: ['src/**/*.test-d.ts'], },},"scripts": { "test:types": "vitest run --typecheck"}The expectTypeOf matcher family
Section titled “The expectTypeOf matcher family”In this chapter’s first lesson you chose runtime matchers by the shape of the value: toBe for primitives, toEqual for objects, toMatchObject for a partial.
Type tests have a parallel family, except you choose by the relationship between two types.
Here is the family in one file, one matcher at a time:
import { expectTypeOf } from 'vitest';import { ok } from './result';import { formatMoney } from '../money';import type { Money, Currency } from '../money';
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();expectTypeOf<InvoiceId>().toExtend<string>();expectTypeOf(invoice).toMatchObjectType<{ id: InvoiceId }>();expectTypeOf(formatMoney).parameters.toEqualTypeOf<[Money, Currency]>();expectTypeOf(formatMoney).returns.toEqualTypeOf<string>();expectTypeOf(invoice.total).toBeNumber();expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();expectTypeOf imports from 'vitest' like every other matcher, with globals: false. It’s erased at runtime and does nothing when called, which is why the typecheck pass is mandatory: nothing here runs.
import { expectTypeOf } from 'vitest';import { ok } from './result';import { formatMoney } from '../money';import type { Money, Currency } from '../money';
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();expectTypeOf<InvoiceId>().toExtend<string>();expectTypeOf(invoice).toMatchObjectType<{ id: InvoiceId }>();expectTypeOf(formatMoney).parameters.toEqualTypeOf<[Money, Currency]>();expectTypeOf(formatMoney).returns.toEqualTypeOf<string>();expectTypeOf(invoice.total).toBeNumber();expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();The wrapper takes a value, as in expectTypeOf(ok(invoice)), and exposes its type; the angle-bracket form expectTypeOf<T>() takes a type directly. .toEqualTypeOf<T>() is exact, bidirectional equality, “exactly T, no wider, no narrower,” and your default reach.
import { expectTypeOf } from 'vitest';import { ok } from './result';import { formatMoney } from '../money';import type { Money, Currency } from '../money';
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();expectTypeOf<InvoiceId>().toExtend<string>();expectTypeOf(invoice).toMatchObjectType<{ id: InvoiceId }>();expectTypeOf(formatMoney).parameters.toEqualTypeOf<[Money, Currency]>();expectTypeOf(formatMoney).returns.toEqualTypeOf<string>();expectTypeOf(invoice.total).toBeNumber();expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();.toExtend<T>() is one-way assignability: “the value’s type is a T.” A subtype passes. InvoiceId is a branded string, so it extends string and this holds, but string does not extend InvoiceId. Reach for it when a subtype is acceptable. (It replaces the deprecated toMatchTypeOf.)
import { expectTypeOf } from 'vitest';import { ok } from './result';import { formatMoney } from '../money';import type { Money, Currency } from '../money';
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();expectTypeOf<InvoiceId>().toExtend<string>();expectTypeOf(invoice).toMatchObjectType<{ id: InvoiceId }>();expectTypeOf(formatMoney).parameters.toEqualTypeOf<[Money, Currency]>();expectTypeOf(formatMoney).returns.toEqualTypeOf<string>();expectTypeOf(invoice.total).toBeNumber();expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();.toMatchObjectType<T>() checks an object type against a subset of its keys, the type-level analog of runtime toMatchObject. Here it asserts invoice has at least { id: InvoiceId }, ignoring its other fields.
import { expectTypeOf } from 'vitest';import { ok } from './result';import { formatMoney } from '../money';import type { Money, Currency } from '../money';
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();expectTypeOf<InvoiceId>().toExtend<string>();expectTypeOf(invoice).toMatchObjectType<{ id: InvoiceId }>();expectTypeOf(formatMoney).parameters.toEqualTypeOf<[Money, Currency]>();expectTypeOf(formatMoney).returns.toEqualTypeOf<string>();expectTypeOf(invoice.total).toBeNumber();expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();Navigators drill into a type before you assert on it: .parameters (a tuple of the function’s parameter types), .returns, .items (an array’s element type), .toHaveProperty('x'). Here we pin formatMoney’s parameters and return separately.
import { expectTypeOf } from 'vitest';import { ok } from './result';import { formatMoney } from '../money';import type { Money, Currency } from '../money';
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();expectTypeOf<InvoiceId>().toExtend<string>();expectTypeOf(invoice).toMatchObjectType<{ id: InvoiceId }>();expectTypeOf(formatMoney).parameters.toEqualTypeOf<[Money, Currency]>();expectTypeOf(formatMoney).returns.toEqualTypeOf<string>();expectTypeOf(invoice.total).toBeNumber();expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();Primitive and special checkers read a single type without naming it: .toBeString(), .toBeNumber() (shown), .toBeNever(), .toBeUnknown(), .toBeAny(). A type degraded to any silently passes almost any other assertion, so you usually write .not.toBeAny() to catch that leak.
import { expectTypeOf } from 'vitest';import { ok } from './result';import { formatMoney } from '../money';import type { Money, Currency } from '../money';
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();expectTypeOf<InvoiceId>().toExtend<string>();expectTypeOf(invoice).toMatchObjectType<{ id: InvoiceId }>();expectTypeOf(formatMoney).parameters.toEqualTypeOf<[Money, Currency]>();expectTypeOf(formatMoney).returns.toEqualTypeOf<string>();expectTypeOf(invoice.total).toBeNumber();expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();.not negates any matcher, the workhorse for “must not widen” assertions: .not.toEqualTypeOf<string>() proves the brand is still distinct from string. You’ll lean on it in the next two sections.
Two traps.
First, expectTypeOf(value) with nothing chained after it is a silent no-op, the type-level twin of a runtime test with no expect.
Second, toEqualTypeOf (bidirectional) and toExtend (one-way assignability ) are not interchangeable.
Use toExtend where you meant exact equality and a widened type sails through, because a wider type still satisfies “is-a.”
The wrong choice doesn’t error; it quietly stops testing what you think it tests.
The decision procedure mirrors the matcher-by-shape table from the first lesson:
For a quick one-off there’s a leaner option.
assertType<T>(value) asserts that a single expression has type T, reading like const x: T = value without the throwaway binding:
assertType<Result<Invoice>>(ok(invoice));Reach for assertType to pin one expression in passing; reach for expectTypeOf when the file is a test and you want navigators, negation, and the readable matcher names.
The rest of this lesson is test files, so we use expectTypeOf.
Two terms carry the next sections.
Bidirectional equality is why toEqualTypeOf catches a widened union: a wider type fails the “B assignable to A” half.
Structural typing is why a brand needs a phantom field at all, the heart of the next section.
Pinning a discriminated union and its consumer
Section titled “Pinning a discriminated union and its consumer”The invoice lifecycle is a discriminated union: a state is one of a fixed set of shapes, told apart by a status discriminant.
type InvoiceState = | { status: 'draft'; total: Money } | { status: 'sent'; total: Money; sentAt: Instant } | { status: 'paid'; total: Money; paidAt: Instant };
declare function processInvoice(state: InvoiceState): Money;
expectTypeOf<InvoiceState>().toEqualTypeOf< { status: 'draft'; total: Money } | { status: 'sent'; total: Money; sentAt: Instant } | { status: 'paid'; total: Money; paidAt: Instant }>();
expectTypeOf(processInvoice).parameters.toEqualTypeOf<[InvoiceState]>();The first assertion pins the union to its exact members, and toEqualTypeOf is what makes that work.
Add a fourth case, { status: 'cancelled'; ... }, and the union is now wider than the literal on the right, so the bidirectional check fails the moment the drift appears.
With .toExtend, the widened union would still pass, since a wider type is still a supertype, and the new case would slip in unnoticed.
The second assertion pins the consumer.
Adding cancelled without teaching processInvoice to handle it now surfaces at the type-test boundary, instead of waiting for a runtime path that happens to exercise the new state.
This pairs with the assertNever exhaustiveness check you already know.
The switch fails the build if processInvoice’s body forgets a case; the type test fails if the union or its signature drifts, guarding it from both sides.
Keeping branded IDs from collapsing to string
Section titled “Keeping branded IDs from collapsing to string”Start with two IDs, both built on string:
type InvoiceId = Brand<string, 'InvoiceId'>;type UserId = Brand<string, 'UserId'>;These look obviously different, but they aren’t to the compiler.
TypeScript is structurally typed, comparing types by their members, not their names.
Strip the brand away and both are just string.
What gives them distinct shapes is the phantom field that Brand<T, Name> adds.
So the two assertions that matter for a brand are both negative:
type InvoiceId = Brand<string, 'InvoiceId'>;type UserId = Brand<string, 'UserId'>;
expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();expectTypeOf<InvoiceId>().not.toExtend<UserId>();The first proves the brand hasn’t been widened back to string, catching the opening regression.
The second proves the IDs don’t cross-assign: handing a UserId where an InvoiceId is wanted must be a type error.
Both are negative because brand bugs are bugs of absence: something protective was removed, a brand dropped, a distinction erased. The brand has no runtime presence to inspect, so you can’t assert it’s there; you assert instead that the bad thing is impossible.
type InvoiceId = Brand<string, 'InvoiceId'>;Now prove it yourself.
Below, one line should be a type error: assigning a UserId where an InvoiceId is expected.
It’s marked with a // @ts-expect-error directive, which expects the next line to fail type-checking; if that line ever type-checks, the directive itself errors with “unused @ts-expect-error directive.”
A passing type test here means the bad assignment is correctly rejected.
Make every error in the panel go away.
One assignment below should be rejected by the type checker, and one assertion is left incomplete. Complete the assertion (replace the ____ blank) and make every error in the panel go away — including the one the @ts-expect-error directive is guarding.
- Fix all errors
Reference solution
The cross-assignment stays as-is: leaving the brand intact is what keeps @ts-expect-error satisfied, since the assignment should fail. The completed assertion:
expectTypeOf<InvoiceId>().not.toEqualTypeOf<string>();Swap the brand on InvoiceId back to a plain string and two things break at once: the cross-assignment now type-checks, so @ts-expect-error goes red as “unused,” and .not.toEqualTypeOf<string>() now asks the checker to prove InvoiceId differs from string, which it no longer does. That double failure is the opening regression, caught at the type boundary.
Pinning the Result contract
Section titled “Pinning the Result contract”Every /lib function in this chapter returns the Result<T> shape instead of throwing:
export type Result<T> = | { ok: true; data: T } | { ok: false; error: { code: 'not_found' | 'conflict' | 'internal'; userMessage: string; fieldErrors?: Record<string, string[]>; }; };This is a discriminated union on ok, so the same two angles apply: pin the constructor’s output, and pin that consumers narrow before they touch the payload.
expectTypeOf(ok(invoice)).toEqualTypeOf<{ ok: true; data: Invoice }>();Pin what ok() returns. The ok constructor should produce { ok: true; data: Invoice } exactly, not a widened { ok: true; data: unknown }; if a refactor lets data widen, this assertion fails and names it. (Asserting against Extract<Result<Invoice>, { ok: true }> works too, and reads cleaner once the shape grows.)
declare const result: Result<Invoice>;
// @ts-expect-error data doesn't exist until `ok` is checkedconst total = result.data;
if (result.ok) { expectTypeOf(result.data).toEqualTypeOf<Invoice>();}The discriminated union enforces the order. The marked line reaches for data before narrowing on result.ok; that’s a compile error, so the @ts-expect-error keeps the snippet green. Inside the if, data exists as Invoice, and the assertion confirms it. The checker makes “check before you read” a structural rule.
This second variant proves the type system forces you to narrow before reading data; asserting that a real err value carries the right code and message is value-level work for the last lesson of this chapter.
This pays off most when a type is derived from a single source of truth. When a Zod schema or a Drizzle table is the source, a hand-written domain type can quietly drift from it, and a one-line type test catches that the instant it happens:
expectTypeOf<z.infer<typeof invoiceSchema>>().toEqualTypeOf<Invoice>();Edit the schema but not the Invoice type, or the reverse, and the two stop being equal and the test goes red.
You’re not re-testing Zod or Drizzle; you’re testing that your derived type and your schema haven’t drifted apart.
Now operate the matcher yourself.
Read the inferred type under the ^? query, then correct the toEqualTypeOf argument so the test passes.
The ^? query shows the type ok(invoice) actually returns. The toEqualTypeOf argument below it has drifted and no longer matches — correct the argument so the type test passes and the panel is clear.
-
Type query at line 18 must resolve to a type containing
data:
Reference solution
The ^? query resolves to { ok: true; data: Invoice }, so the asserted argument must match what ok() returns:
expectTypeOf(result).toEqualTypeOf<{ ok: true; data: Invoice }>();With data: unknown the two types aren’t equal: toEqualTypeOf is bidirectional, so a widened unknown fails the “asserted is assignable to actual” half and the assertion reports the drift. Swap unknown for Invoice and the type test goes quiet.
Pinning generic inference
Section titled “Pinning generic inference”Generics are where types silently widen, and where runtime tests are blindest.
Consider a mapper over Result that transforms the success payload and leaves a failure untouched:
declare function mapResult<T, U>( result: Result<T>, fn: (value: T) => U,): Result<U>;
expectTypeOf(mapResult(ok(1), (n) => n.toString())).toEqualTypeOf<Result<string>>();The assertion pins the inference: feed in a Result<number> and a number => string mapper, and U must infer as string, making the result Result<string>.
If a refactor breaks the inference chain so U widens to unknown, the assertion fails, while a runtime test never notices, the string comes back either way.
One failure mode needs an explicit guard.
A generic that has quietly degraded to any passes toEqualTypeOf silently, because any is assignable in both directions, so add:
expectTypeOf(mapResult(ok(1), (n) => n.toString())).not.toBeAny();An assertion that passes because the type is any tests nothing; .not.toBeAny() proves the type is real before you trust the rest.
Type tests are not runtime tests
Section titled “Type tests are not runtime tests”Three different claims need three different checks:
- “An
InvoiceStatehas exactly these three members” → a type test. - “
processInvoiceof a paid invoice returns the paid total” → a runtime test. - “This runtime input is actually a string” →
expect(typeof x).toBe('string'), a value assertion about runtime data, checked by neither type matcher.
In CI, vitest run --typecheck gates the build alongside the runtime pass.
Coverage skips type tests, since there’s no runtime to instrument.
Sort each regression into the checker that would catch it.
Sort each regression into the checker that catches it — the type checker (a `*.test-d.ts` assertion) or the Vitest runner (a runtime test). Drag each item into the bucket it belongs to, then press Check.
InvoiceState gains a Cancelled memberInvoiceId is widened back to plain stringunknownprocessInvoice is off by one on the totalok() returns data: undefined at runtimeUserId where an InvoiceId is expected stops being an errorExternal resources
Section titled “External resources”The canonical guide: the --typecheck flag, *.test-d.ts files, and the expectTypeOf / assertType APIs.
The library behind expectTypeOf — the full matcher catalog, including toExtend, toMatchObjectType, and the navigators.
The Handbook on structural typing — why a brand needs a phantom field and how toEqualTypeOf reasons about assignability.
Matt Pocock's hands-on walkthrough of the brand pattern this lesson's negative assertions defend.