Definition
Type-level boundary value analysis applies boundary value analysis (BVA) from test design to the type level. It's about picking which type test cases to write at the boundaries.
You already know the runtime version. Given a "minimum 2 characters" rule, you check 1, 2, and 3 characters. Defects cluster at the boundaries, not in the middle of a range.
Types work the same way. Only, a type's boundary isn't the size of a value. It's the extreme of the type lattice. Between never and unknown, between a literal and a widened string, between an omitted property and an explicit undefined — those are the min−1, min, and min+1 of types.
Why you need it
The first question you hit when writing type tests is "what do I even write?" And the most common answer here is to decide by count, as in "let's write 3 negative cases".
Here's what happens once a count is the criterion: you fill the number.
// @ts-expect-error column doesn't exist
const a: SortKey = "ordreNo";
// @ts-expect-error column doesn't exist (only the typo differs)
const b: SortKey = "ordrNo";
// @ts-expect-error column doesn't exist (again)
const c: SortKey = "orderNumber";
Three cases filled. But all three touched the same boundary three times. Whether the literal widens to string, whether readonly arrays are accepted, where the inference authority sits — none of that got looked at. The count is full and the detection power is unchanged.
Picking by boundary makes that problem go away. One pair of witnesses per boundary is enough, and overlapping cases fall out naturally. And what you didn't look at is left behind as a list.
How it works
First: the axes types cover and the ones they don't
Runtime BVA usually looks at four axes. Before moving them to the type level, you have to decide how far they actually move.
| Runtime BVA axis | Representative boundary | Do types cover it |
|---|---|---|
| value boundary | min−1 / min / min+1, empty values, format | Covered — but as the extreme of the type lattice, not as size |
| state boundary | right after each transition (idle→pending, etc.) | Partly — up to "representable combinations". Types can't see the transition actually happen |
| time and ordering boundary | out-of-order responses, arrival after unmount | Not covered |
| side-effect count boundary | 0 times / exactly once / 2 or more | Not covered |
The two lines below are the most important part of this document. The time axis and side-effect counts are not provable with types. No matter how carefully you craft a union, it won't stop a request from going out twice. That territory belongs to runtime tests, and finishing all your type tests doesn't cover those axes.
The state boundary is "partly" for the same reason. Types can make impossible combinations like { status: 'loading'; error: Error } unrepresentable, but whether loading actually clears right after a failure is something you only learn by running it.
The 7 axes of the type lattice
Move the value boundary into types and these axes come out. Each axis is one boundary, and each boundary gets one passing witness and one rejecting witness.
Axis 1. union members — the boundary is "member / non-member"
type SortKey = "orderNo" | "total";
const ok: SortKey = "total";
// @ts-expect-error a non-member must be rejected
const bad: SortKey = "userId";
Axis 2. literal widening — the boundary is "literal / widened string"
This is a must-check axis for any API that derives types from values. Once it widens, all the protection from axis 1 disappears wholesale.
const cols = defineColumns([{ id: "orderNo" }, { id: "total" }]);
type Key = ColumnIdOf<typeof cols>;
const ok: Key = "total"; // passes only if the literal survives
// @ts-expect-error a value already widened to string can't get in
const bad: Key = "total" as string;
Axis 3. optional — the boundary is "omitted property / explicit undefined"
The verdict changes with the effective exactOptionalPropertyTypes setting. Check the setting before you use this axis.
type Options = { retry?: number };
const ok: Options = {}; // omission is allowed
// @ts-expect-error under exactOptionalPropertyTypes, an explicit undefined is rejected
const bad: Options = { retry: undefined };
Axis 4. readonly — the boundary is "accepts readonly input / loses the modifier"
Check whether an API that doesn't mutate its input takes readonly T[] and as const tuples, and whether mapped types keep readonly.
const frozen = [{ id: "orderNo" }] as const;
const ok = defineColumns(frozen); // must accept a readonly tuple
type Mapped = { readonly [K in keyof Config]: Config[K] };
// @ts-expect-error if the modifier is preserved, the assignment is blocked
const bad: Mapped = mutableConfig;
Axis 5. never, any, unknown — the boundary is "bottom / hole / top of the lattice"
Use this axis only when a distributive conditional is part of the contract. Otherwise, don't build it.
type Boxed<T> = [T] extends [string] ? "yes" : "no";
type Distributed<T> = T extends string ? "yes" : "no";
const a: Distributed<never> = "no" as never; // never distributes into never
const b: Boxed<never> = "yes"; // boxing stops distribution
// @ts-expect-error any satisfies both branches, so it becomes a union
const c: Distributed<any> = "yes" as const;
Axis 6. tuple arity — the boundary is "empty tuple / 1 / n"
Use it only when the contract actually distinguishes lengths. APIs that preserve variadic argument relationships belong here.
const one = defineRoutes(["/home"]);
const many = defineRoutes(["/home", "/settings"]);
// @ts-expect-error an empty list means no routes, so it's rejected
const none = defineRoutes([]);
Axis 7. inference authority — the boundary is "takes part in inference / doesn't"
If you use NoInfer or const type parameters, which argument holds the authority is the contract.
function pick<T>(options: readonly T[], fallback: NoInfer<T>): T;
const ok = pick(["a", "b"], "a"); // T is inferred from options only
// @ts-expect-error fallback can't widen the union
const bad = pick(["a", "b"], "z");
Applying it in practice
Three steps
Step 1 — pick the axes, and write down a reason for the ones you skipped.
Pick only the axes this API actually closes, and leave one line on why the rest don't apply. That list is exactly what review looks at.
| Axis | Applies | Reason |
| --------------- | ------- | ------------------------------------------ |
| union members | Yes | exposes sort keys as a closed union |
| literal widening| Yes | derives the union from a column array |
| inference authority | Yes | preserves literals via const type parameter |
| optional | - | no optional properties |
| readonly | - | doesn't mutate input, but takes no arrays |
| never/any/unknown | - | not a distributive conditional |
| tuple arity | - | no contract distinguishes length |
Filling in witnesses for axes you don't close is the same as filling a count. Don't attach the same checklist to every type.
Step 2 — write one pair of witnesses per axis.
One passing, one @ts-expect-error. And put only one misuse in a single @ts-expect-error line. Pack in several and one unrelated error is enough to pass.
// axis: literal widening
const ok: Key = "total";
// @ts-expect-error a widened string is rejected — this line owns one axis
const bad: Key = "total" as string;
On top of that, keep one valid-call witness for the whole file. It checks that it compiles without explicit type arguments. If that doesn't work, passing every negative case still doesn't make it a usable API.
Step 3 — confirm it's alive with a mutation.
Break the contract on purpose for each axis and check that that axis's witness goes red.
| Axis | How to break it | Expectation |
|---|---|---|
| literal widening | drop const from the const type parameter | that axis's witness goes RED |
| inference authority | NoInfer<T> → T | that axis's witness goes RED |
| union members | widen the union to string | that axis's witness goes RED |
| optional | turn off exactOptionalPropertyTypes | that axis's witness goes RED |
If an axis doesn't go red, that witness was guarding nothing from the start.
Write down what you hand off to runtime
Once the type axes are filled, hand off the two uncovered axes explicitly.
- time and ordering: out-of-order requests, arrival after unmount → abort signal + runtime test
- side-effect count: exactly one request on duplicate submit → runtime test
Without those lines, "the type tests are all written" reads as "everything is verified". That's the most common accident with this method.
Trade-offs
Picking by axis usually shrinks the number of cases. An API where you'd have written 3 cases by count may come out to just 2 by axis. It looks thin at first, but detection power actually goes up, because overlapping cases drop out and boundaries you never looked at come in.
Conversely, some APIs have a lot of axes. If all 7 apply, that's 14 witnesses. The right way to read that isn't "the types are complex" but "the API surface is wide". If it keeps growing, don't add cases — split the API. (Past 30 @ts-expect-error lines on one API, that's the moment.)
Choosing the axes is itself a judgment, and that costs something. It asks for more thought than mechanically filling in a checklist. In exchange, the judgment is written down and becomes reviewable — someone can ask "why didn't you look at this axis?"
When not to use it
- Local state and internal helpers. With a handful of call sites in the same file, the cost of reasoning about axes exceeds the gain. Type tests themselves are overkill.
- Trying to substitute for runtime validation. External input is checked by a parser, not an axis. Don't report that you validated a URL because you used a template literal type.
- Filling all 7 axes on every API. This is the surest way to ruin the method. The axis list is a menu to choose from, not a form to fill in.
- Using the optional and readonly axes without knowing the environment. If
exactOptionalPropertyTypesis off, axis 3's rejecting witness doesn't hold.
Common mistakes
- Touching the same boundary several times. Three typos are one axis. Count them as one case, not three.
- Building witnesses for axes you don't even close. Adding
never,any, andunknownwhen there's no distributive conditional only grows the maintenance load. - Packing several misuses into one
@ts-expect-errorline. One unrelated error passes it, and you never notice the axis is empty. - Using
@ts-ignore. It stays quiet even when the error vanishes, so you can't catch an axis that collapsed. Always@ts-expect-error. - Forgetting the valid-call witness. Check only rejections and an "API that lets nothing through" gets full marks.
- Skipping the mutation check. Without breaking each axis on purpose, you can't tell that a witness is dead.
- Reporting that types covered the time axis and side-effect counts. Those two axes are still there after the type tests are done.
Related concepts
- type-level-testing — the tooling that makes witnesses actually runnable (
.test-d.ts,@ts-expect-error, mutation) - test-oracle — the question of where "what is correct" comes from. At the type level, "what must not compile" is that oracle
- typescript-environment-contract — the compiler settings that decide the verdict on the optional and readonly axes
- derive-types-from-values — the place where the literal widening axis is needed most often