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.
Table of Contents
- 1. Why a type that compiles can still be wrong
- 2. Type-level tests vs. classic unit tests
- 3. tsd: expectType and expectError in .test-d.ts files
- 4. expect-type: framework-agnostic expectTypeOf assertions
- 5. Vitest's built-in expectTypeOf: type checks without an extra package
- 6. Practical example: testing DeepPartial<T> against real edge cases
- 7. Pitfalls: structural compatibility instead of exact equality
- 8. CI integration: pure compile-time checking without runtime
- 9. tsd, expect-type, and Vitest's expectTypeOf compared
- 10. Summary
- 11. FAQ
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.