Type Testing: Testing Types Themselves with tsd and expect-type
AI generated
<T>
type
TypeScript · Type Testing · tsd · Vitest
Type Testing: Testing Types Themselves with tsd and expect-type
Why a type that compiles can still be wrong

A TypeScript type that compiles without errors is not automatically correct. Complex generic and conditional types can silently widen to any, accept an invalid shape they should reject, or narrow incorrectly inside a conditional branch. Tools like tsd, expect-type, and Vitest's expectTypeOf make exactly this behavior testable, purely at compile time, without a single runtime test.

13 min. read tsd · expect-type · expectTypeOf TypeScript 5.x · Vitest · CI/CD

1. Why a type that compiles can still be wrong

A TypeScript type that compiles without errors is nowhere near guaranteed to be semantically correct. The compiler only checks whether an assignment is structurally allowed, not whether a hand-written utility type like DeepPartial or PickByValue actually does what its name promises. A poorly constructed conditional type can silently widen to any, accept an invalid object shape it should reject, or fail to narrow at all inside a conditional branch. The bug only surfaces where the type gets used, often far away from where it was defined.

That risk grows with the complexity of modern type-level code: mapped types, conditional types, template literal types, and recursive types combine freely, and a single broken branch rarely stands out on its own. A type is, in the end, just as error-prone as any function, except it runs at compile time instead of runtime. Maintaining complex types without dedicated tests means relying on lucky catches in IDE tooltips instead of systematically preventing regressions.

2. Type-level tests vs. classic unit tests

A classic unit test executes code and checks values at runtime: a function gets called, and the result is compared with assert or expect. A type-level test does something fundamentally different: it executes nothing at all, and instead lets the TypeScript compiler check whether a type inferred by the type system exactly matches an expected type. The assertion exists only during compilation; nothing remains of it in the built JavaScript, often not even a function call.

This difference has direct consequences for test coverage: a runtime test cannot detect that a generic function suddenly returns any for a specific input type, as long as the value happens to be correct at runtime anyway. A type-level test catches exactly that immediately, because it inspects the inferred type itself, not runtime behavior. Both kinds of tests complement each other: runtime tests secure behavior, type-level tests secure the public type surface of a library or an internal utility type.

3. tsd: expectType and expectError in .test-d.ts files

tsd is the most established tool for type testing in TypeScript libraries. It scans a project for files ending in .test-d.ts, compiles them with the TypeScript compiler, and evaluates two special functions: expectType<T>(value) checks whether the inferred type of value is exactly T, not merely compatible with it. expectError(expression) marks a line that must produce a compiler error for the test to pass. If the expected error doesn't appear, tsd itself reports a failure.

The decisive advantage of tsd: it checks the actually published .d.ts declaration files of a package, exactly what consumers of the library get to see. That makes tsd especially valuable for library authors who want to prevent a refactor from silently changing the public type API. Because tsd runs entirely through the compiler, it needs no test runner and no runtime environment; a simple npx tsd call is enough.


// deep-partial.ts
// DeepPartial<T>: recursively makes nested properties optional
type DeepPartial<T> = T extends (...args: unknown[]) => unknown
  ? T // do not recurse into function types, keep them intact
  : T extends readonly (infer U)[]
    ? readonly DeepPartial<U>[]
    : T extends object
      ? { [K in keyof T]?: DeepPartial<T[K]> }
      : T;

export interface UserProfile {
  id: string;
  address: {
    city: string;
    zip: string;
  };
  onSave: () => void;
}

export type { DeepPartial };

// deep-partial.test-d.ts
import { expectType, expectError } from 'tsd';
import type { DeepPartial, UserProfile } from './deep-partial';

const patch: DeepPartial<UserProfile> = {
  address: { city: 'Berlin' },
};

// Passing case: a partial nested patch is assignable and typed correctly
expectType<DeepPartial<UserProfile>>(patch);

// Compile error expected: zip must stay a string, never silently become "any"
expectError<DeepPartial<UserProfile>>({ address: { zip: 12345 } });

4. expect-type: framework-agnostic expectTypeOf assertions

expect-type takes a different approach than tsd: instead of separate .test-d.ts files with their own naming convention, assertions can be placed directly in regular test files or even in application code. Its central API is expectTypeOf<T>(), chainable with methods like .toEqualTypeOf<U>() for exact equality or .toMatchTypeOf<U>() for plain assignability. Nothing happens at runtime: every call is a pure type operation with no side effect, and the compiled JavaScript output is, at most, an empty function call.

Because expect-type doesn't force its own file convention, it embeds seamlessly into existing Jest, Mocha, or Vitest suites without extra build steps. That lowers the barrier for teams that want to introduce type tests gradually alongside existing unit tests, instead of setting up an entirely separate tool chain like tsd requires. The downside: without type checking actually enabled during the test run, the assertions get silently skipped, more on that in the CI integration section.


// pick-by-value.spec.ts
import { expectTypeOf } from 'expect-type';
import type { PickByValue } from './pick-by-value';

interface Flags {
  isActive: boolean;
  isAdmin: boolean;
  label: string;
  count: number;
}

type BooleanFlags = PickByValue<Flags, boolean>;

// Correct: exact equality catches extra or missing keys
expectTypeOf<BooleanFlags>().toEqualTypeOf<{ isActive: boolean; isAdmin: boolean }>();

// Pitfall: toMatchTypeOf only checks assignability, so a BooleanFlags type
// that accidentally still contains "label: string" would pass this check too
expectTypeOf<BooleanFlags>().toMatchTypeOf<{ isActive: boolean }>();

5. Vitest's built-in expectTypeOf: type checks without an extra package

Vitest has shipped its own expectTypeOf API for several versions now, conceptually close to expect-type but built directly into the test runner. That lets type-level assertions live in the same .test.ts file as their corresponding runtime tests, with no extra package to install. The assertions themselves are treated as no-ops at runtime; they only get evaluated when Vitest runs in typecheck mode.

That mode is exactly the biggest trap: a plain vitest run executes the test file but completely ignores the expectTypeOf calls while doing so, the test shows green even though no type checking happened at all. Only vitest --typecheck, or an enabled typecheck option in the Vitest config, spins up a separate TypeScript process in the background that actually evaluates the assertions. Miss that detail, and the test suite fakes confidence it never actually delivers.


// unwrap-promise.test.ts
import { describe, it, expectTypeOf } from 'vitest';
import type { UnwrapPromise } from './unwrap-promise';

describe('UnwrapPromise', () => {
  it('unwraps a resolved promise value', () => {
    expectTypeOf<UnwrapPromise<Promise<string>>>().toEqualTypeOf<string>();
  });

  it('leaves non-promise types untouched', () => {
    expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
  });

  // Runs only under "vitest --typecheck", a plain "vitest run" silently
  // skips this assertion without reporting a failure
});

6. Practical example: testing DeepPartial<T> against real edge cases

A DeepPartial<T> utility type is meant to recursively make nested object properties optional, for example for patch objects in an update function. The obvious recursive definition via a conditional type looks harmless, but has typical edge cases: function properties must not be recursed into, otherwise a method turns into an optional object with call-signature-like properties. Arrays need their own case distinction too, otherwise string[] accidentally turns into (string | undefined)[] or, worse, any[].

A good type test for DeepPartial therefore covers at least two cases: a passing case that shows a nested object correctly becoming fully optional, and a case that's expected as a compile error, for example when a field should still require a concrete type like string instead of accidentally accepting any. Only both cases together prove that the type both permits the right shape and reliably rejects the wrong one.

7. Pitfalls: structural compatibility instead of exact equality

The most common mistake in type testing: an assertion only checks whether a type is assignable to the expected type, not whether it exactly matches it. expectAssignable in tsd or toMatchTypeOf in expect-type exploit TypeScript's structural typing: a type that's too wide, carrying extra properties it shouldn't have, still passes the check because it remains compatible. A type that's too narrow, or accidentally widened to any, often doesn't get flagged by such checks at all, because any is assignable to nearly anything.

Anyone who wants to prevent real regressions needs expectType or toEqualTypeOf as the default tool for invariant equality, reserving expectAssignable/toMatchTypeOf for the specific cases where compatibility really is the goal. A single // @ts-expect-error without an accompanying assertion isn't enough for serious coverage either: it only proves that some error occurs on that line, not which one, and it says nothing about the correct, positive case. An error that shifts to a different line silently strips the comment of its effect.

8. CI integration: pure compile-time checking without runtime

Type-level tests run entirely through the TypeScript compiler, never through a JavaScript runtime. tsd internally spins up its own tsc process against the configured .test-d.ts files, without ever executing code, which makes the tests comparatively fast and independent of mocking or test data. Vitest in typecheck mode behaves similarly: a separate TypeScript language service process runs in the background alongside the actual test runner, reporting errors directly as test failures.

Type tests belong in the CI pipeline as their own explicit step next to regular unit tests: an npm run test:types script that runs tsc --noEmit, tsd, and where relevant vitest --typecheck in sequence. It's important not to make that step optional, because unlike a runtime error, a failed type test never automatically breaks the build if it gets accidentally skipped. Especially for published npm packages, this step prevents a broken public type API from going live unnoticed.


#!/usr/bin/env bash
# ci-type-tests.sh: compile-time only, no runtime execution
set -euo pipefail

echo "Running tsc structural check..."
npx tsc --noEmit

echo "Running tsd type tests (*.test-d.ts)..."
npx tsd

echo "Running Vitest type-level assertions..."
npx vitest --typecheck --run

9. tsd, expect-type, and Vitest's expectTypeOf compared

The three tools presented here solve the same problem with different integration effort and different strictness. The table below shows when each tool fits and which pitfall is typical for it.

Tool When to use Typical pitfall Execution
tsd Locking down a library's public .d.ts API expectError only proves "some error", not which one Runs via npx tsd, no test runner needed
expect-type Dropping assertions straight into existing test files Mixing up toMatchTypeOf with toEqualTypeOf Pure type operation, zero runtime overhead
Vitest expectTypeOf Type tests living next to unit tests in one file Forgetting the typecheck flag, test shows green without checking Only active with vitest --typecheck
tsc --noEmit alone Minimal setup with no extra test package No assertion API, only global compile errors Runs directly off the existing tsconfig.json
@ts-expect-error alone Flagging a single known error line Doesn't prove which error occurs, no positive case No systematic type testing possible

In practice, these tools aren't mutually exclusive: library authors often rely on tsd for the public .d.ts surface, while internal utility types get covered directly via Vitest's expectTypeOf alongside their corresponding unit tests. What matters isn't the specific tool but the discipline of checking exact equality instead of mere compatibility, and covering both the positive and the negative case.

Mironsoft

TypeScript tooling, type safety, and CI pipelines for Magento and headless projects

Ready to build a type-safe TypeScript codebase?

We set up type testing with tsd, expect-type, or Vitest's expectTypeOf in your codebase, define CI gates for critical utility types, and make sure complex conditional types never break unnoticed.

Type-testing setup

Setting up tsd, expect-type, or Vitest expectTypeOf to match your stack

CI pipeline integration

Making type tests a mandatory build step alongside unit and E2E tests

Utility type review

Auditing existing conditional and mapped types for edge cases and any leaks

10. Summary

Type testing solves a problem classic unit tests structurally cannot cover: a type can compile without errors and still be wrong, because it widens to any, accepts an invalid shape, or fails to narrow correctly inside a conditional type branch. tsd checks a library's public .d.ts declarations for this via dedicated .test-d.ts files and the expectType and expectError functions. expect-type and Vitest's built-in expectTypeOf allow the same kind of assertion directly inside regular test files, entirely without runtime overhead.

What makes type tests reliable is choosing the right assertion: expectType or toEqualTypeOf check exact equality and reliably catch both overly wide types and types accidentally widened to any, while plain assignability checks and a lone @ts-expect-error comment often let real regressions slip through. In the CI pipeline, type tests belong as their own, non-optional step next to regular unit tests, because only an actively running tsc or typecheck process reliably surfaces type errors before they ship.

Type Testing with tsd and expect-type - The Essentials at a Glance

tsd

expectType/expectError in .test-d.ts files, checks a library's public .d.ts API exactly.

expect-type

expectTypeOf right inside test code, runtime-free, ideal for gradual adoption.

Vitest expectTypeOf

Built-in type checks, only active with vitest --typecheck, otherwise a silent no-op.

Exact equality

Use toEqualTypeOf/expectType instead of toMatchTypeOf/expectAssignable to catch any and overly wide types.

11. FAQ: Type Testing with tsd and expect-type

1What is type testing and how does it differ from normal unit tests?
Checks whether a type inferred by the compiler exactly matches the expected type, without executing code. Unit tests run at runtime, type tests run entirely inside the compiler.
2Why can a TypeScript type compile and still be wrong?
The compiler only checks structural validity, not semantic correctness. A conditional type can silently widen to any or fail to narrow correctly.
3What exactly does tsd do and how are .test-d.ts files structured?
Looks for files ending in .test-d.ts, compiles them with tsc, and evaluates expectType and expectError calls against the published .d.ts declarations.
4What's the difference between expectType and expectError in tsd?
expectType checks exact type equality of a value. expectError marks a line that must produce a compiler error for the test to pass.
5What does expect-type offer over tsd?
No dedicated file convention, expectTypeOf embeds directly into existing Jest, Mocha, or Vitest test files, all assertions are pure no-ops at runtime.
6How does Vitest's built-in expectTypeOf work?
Its own API right inside the test runner, evaluated only in typecheck mode with vitest --typecheck, otherwise the calls are silently skipped.
7What's the difference between toEqualTypeOf and toMatchTypeOf?
toEqualTypeOf checks exact equality and catches overly wide or any-widened types. toMatchTypeOf only checks assignability and lets unwanted extra properties through.
8Is // @ts-expect-error alone enough for type tests?
No. It only proves some error occurs on that line, not which one, and says nothing about the correct case. Also needs expectType or toEqualTypeOf.
9How are type tests wired into the CI pipeline?
As their own, non-optional step, e.g. an npm script with tsc --noEmit, tsd, and vitest --typecheck. All three run purely through the compiler.
10What kind of code benefits most from type testing?
Public utility types, library APIs, complex conditional/mapped types, and generic functions with type-dependent return values, where any leaks happen most often.