Definition
A type-level test doesn't run your code to check the result. It checks whether the code compiles or not.
Where an ordinary test asks "what value comes out when I call this function", a type-level test asks "is this code even writable in the first place". You name the file .test-d.ts (or .test-d.tsx if there's JSX), and instead of executing, the type checker decides whether it passes.
Why you need it
It's easy to build an elaborate type and then say "we're safe now". The problem is that the claim usually goes unverified.
Let's look at a concrete scenario. You built a type that derives a union of sort keys from a column array. Then someone refactoring nudges a generic constraint, and as a result the union widens to string.
Your production code still compiles, all of it. A typo like columnId: 'ordreNo' sails through. Nobody notices the protection is gone. The next person who makes a typo gets caught in QA — or doesn't, and it ships.
Type-level tests remove that silence. There's exactly one question to judge by.
Of the wrong code you could have written, what no longer compiles?
Type complexity you can't answer that question about concretely is better left out entirely. And if you can answer it, the answer is your test case.
How it works
The core tool is a single line: @ts-expect-error. It means "the next line must produce a type error".
// @ts-expect-error a column id that doesn't exist
const bad: SortKey = "ordreNo";
If the error occurs, this line swallows it and passes. But when the error disappears — meaning the protection has been neutralized — this line raises a separate error, "unused @ts-expect-error" (TS2578), and the test goes red.
The structure is that losing the protection is the failure. That's the decisive difference from @ts-ignore. @ts-ignore stays quiet whether or not there's an error, so it protects nothing.
Bring five kinds of evidence
| Evidence | Criterion | What happens without it |
|---|---|---|
| positive | a representative valid call compiles without explicit type arguments | you get a safe API nobody uses |
| negative | one per boundary axis this API actually closes, one line each | nobody can tell what it blocks |
| edge | boundary behavior for any, unknown, never, readonly tuples, etc. | holes remain that only open at the edges |
| mutation | weakening the contract actually turns the test RED | you can't tell that the test guards nothing |
| soundness gap | the holes types can't close are written down | you get a false sense of safety |
The environment is the judging criterion
"Doesn't compile" is a function of your tsconfig. With strict off, code that should be blocked isn't; with an older TypeScript version, you can't even use const type parameters or NoInfer.
So in a repo where you're building type contracts for the first time, record two things up front: the effective settings confirmed with tsc --showConfig and the compiler version the lockfile actually resolved. A result that only passes in a Playground or on the latest version is not evidence.
How to choose your cases
Which cases you write is decided by the boundary, not the count. Make a count the target — "3 negative cases" — and you end up with 3 cases that all poke at the same boundary.
A boundary is an extreme of the type lattice — places like a union member vs. a non-member, a literal vs. a widened string, an omitted property vs. an explicit undefined. Put one passing witness and one @ts-expect-error on each boundary this API actually closes, and build nothing for the boundaries it doesn't.
How to pick them is covered in a separate doc.
Applying it in practice
// columns.test-d.ts
import { defineColumns, type ColumnIdOf } from "./columns";
const cols = defineColumns([
{ id: "orderNo", header: "Order No." },
{ id: "total", header: "Total" },
]);
type SortKey = ColumnIdOf<typeof cols>;
// ── positive: a valid call infers without type arguments
const ok: SortKey = "total";
// ── negative: one per axis, one misuse per line
// @ts-expect-error typo — column doesn't exist
const typo: SortKey = "ordreNo";
// @ts-expect-error column id from a different table
const foreign: SortKey = "userId";
// @ts-expect-error a wide string isn't allowed
const wide: SortKey = "total" as string;
The mutation check procedure
Writing the test isn't the end. You have to confirm that the test is actually alive.
- Deliberately weaken the contract. Drop the
constfrom<const T extends ...>, or changeNoInfer<T>back toT. - Run the type check. The test should go RED.
- Revert and confirm GREEN.
If it doesn't go RED, that test is guarding nothing. This procedure is a judgment, not code, so doing it once and recording the result is enough.
Check that CI actually includes it
.test-d.ts files never run, so if they're left out of include in tsconfig.json, nobody checks them. Having the file but not the check is a surprisingly common state.
# check whether this file is in the set being type-checked
pnpm exec tsc --noEmit --listFiles | grep test-d
Trade-offs
What you get is a loud failure the moment protection disappears. The scariest thing about type refactoring is "no errors, but the protection is gone" — this stops that.
What you pay comes in two parts.
First, type checking gets slower. You're checking complex types from several angles. Throw recursive types into the mix and it can get noticeably slow.
Second, the tests themselves become maintenance work. When you widen an API on purpose, @ts-expect-error goes red, and a human has to decide whether that's a real regression or an intended change.
That said, this second cost is really a benefit. Relaxing a contract file isn't an implementation decision, it's a policy change. It ought to show up in review.
When not to use it
- Short types internal to an app. If there are only a handful of call sites and they all live in the same file, the cost of type tests outweighs the gain.
- Trying to substitute for behavior verification.
type-validis notbehavior-correct. Reporting that behavior is right because types passed is a false report. - Trying to prove async ordering. Out-of-order responses, duplicate submits, arrivals after unmount — problems on the time axis are not provable with types. Building one union and writing "ordering problem solved" is a lie. Runtime tests own this.
- Trying to substitute for external input validation. API responses and
localStoragevalues are checked by a runtime parser, not a type assertion. You can't reportasorsatisfiesas verification.
Common mistakes
- Picking cases by count. You end up with 3 cases that all poke at the same boundary. Picking by boundary is covered in type-level-bva.
- Packing several misuses into one
@ts-expect-errorline. Then a single unrelated error is enough to pass. One line per misuse. - Using
@ts-ignore. It stays quiet even when the error vanishes, so it guards nothing. Always use@ts-expect-error. - Skipping the mutation check. If you never confirm the test is alive, you can't tell that it was dead from the start.
- Forgetting the valid-call test. If you block misuse but a valid call doesn't infer without type arguments, that's not a good API. Nobody will use it.
.test-d.tsisn't in the checked set. The file exists but CI never looks at it. Since it isn't a test that runs, nothing draws your attention to it.- An implementation diff deletes
@ts-expect-errorto make things GREEN. That's not a pass, that's quietly widening the contract. Review has to treat it separately.
Related concepts
- type-level-bva — how to pick which cases to write, at the boundaries
- test-oracle — the question of where "what is correct" comes from. At the type level, "what must not compile" is that oracle
- mutation-testing — the same idea in reverse: verifying that tests actually catch defects
- derive-types-from-values — the classic place to apply this, catching derivation that gets silently neutralized