If you publish a library, or even a shared internal package, the types are part of the API. Somebody changes a return type from Pick<T, K> to Partial<T>, every runtime test still passes because the JavaScript is identical, and three downstream teams find out when their build breaks or, worse, when it doesn’t break and a field silently becomes optional. Type tests catch that before release. They’re short to write once you know the two building blocks, and the subtle part (comparing types exactly) is a favourite advanced interview question.
Why runtime tests miss type regressions
A Jest or Vitest test checks values. Take a typed pick helper:
export function pick<T extends object, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
const out = {} as Pick<T, K>;
for (const k of keys) out[k] = obj[k];
return out;
}A runtime test like expect(pick(user, ["id"])).toEqual({ id: 1 }) exercises the loop. It says nothing about whether callers get { id: number } or Partial<typeof user> or any back. All three produce the same object at runtime. The contract you promised to consumers lives entirely in the compiler, so the compiler has to be the test runner.
There are two kinds of promise to check. Positive ones: “this call is allowed and returns exactly this type.” Negative ones: “this call must be rejected.” Each needs its own tool.
Testing what must not compile
// @ts-expect-error suppresses the error on the next line, and it reports an error if there’s nothing to suppress. That second behaviour is what makes it a test assertion rather than an escape hatch. It was added in TypeScript 3.9 for exactly this use.
const user = { id: 1, name: "Ada", admin: false };
// @ts-expect-error: "email" is not a key of user
pick(user, ["email"]);
// @ts-expect-error: this line is fine, so the directive itself should fail
pick(user, ["admin"]);Running tsc --strict --noEmit gives one error, for the second directive:
ct1.ts(22,1): error TS2578: Unused '@ts-expect-error' directive.The first directive is satisfied because "email" really isn’t a key. The second one fails the build because the call is valid. If someone later loosened K extends keyof T to K extends string, the first directive would start failing the same way, and you’d know the API had stopped rejecting bad keys.
Two habits make these assertions useful. Put a reason after the directive so a reviewer knows which error was expected. And keep each guarded line small: the directive covers any error on the next line, so a long line with two problems can pass for the wrong reason. @ts-ignore is the wrong tool here because it never complains when nothing is wrong.
Comparing types exactly
For positive tests you want to assert “this type is exactly X”. The obvious attempt is mutual assignability:
type LooseEqual<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;It has two holes that interviewers like to ask about. any is assignable both ways to almost everything, so LooseEqual<any, string> is true. That’s the worst possible failure, because the regression you most want to catch is a return type collapsing to any. It also ignores readonly, since a readonly property and a mutable one are mutually assignable.
The widely used fix compares two generic function types:
export type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
export type Expect<T extends true> = T;The checker can’t resolve T extends A ? 1 : 2 while T is still unknown, so it compares the two deferred conditional types structurally, and that comparison requires A and B to be identical rather than merely assignable. Here’s how the two helpers behave in one file, with both definitions above at lines 1 to 4:
type a = LooseEqual<any, string>; // true (!)
type b = Equal<any, string>; // false
type d = Equal<{ a: 1 } & { b: 2 }, { a: 1; b: 2 }>;
type e = Equal<{ readonly x: 1 }, { x: 1 }>;
type f = LooseEqual<{ readonly x: 1 }, { x: 1 }>;
type _a = Expect<a>; // passes, which is the problem
type _b = Expect<b>; // line 13
type _d = Expect<d>; // line 14
type _e = Expect<e>; // line 15
type _f = Expect<f>; // passes: readonly ignoredct2.ts(13,18): error TS2344: Type 'false' does not satisfy the constraint 'true'.
ct2.ts(14,18): error TS2344: Type 'false' does not satisfy the constraint 'true'.
ct2.ts(15,18): error TS2344: Type 'false' does not satisfy the constraint 'true'.Line 14 is the known quirk of the strict version: { a: 1 } & { b: 2 } and { a: 1; b: 2 } describe the same values, but Equal reports false because one is an intersection and the other is an object literal type. When you hit that, flatten the actual type first with a mapped type such as { [K in keyof T]: T[K] }, or assert against the same intersection. It’s a small price for catching any.
Catching the regression
Now simulate the change from the introduction. Swap the return type of pick to Partial<T> and keep the same test file:
const picked = pick(user, ["id", "name"]);
type _1 = Expect<Equal<typeof picked, { id: number; name: string }>>;
const n: number = picked.id;ct3.ts(11,18): error TS2344: Type 'false' does not satisfy the constraint 'true'.
ct3.ts(12,7): error TS2322: Type 'number | undefined' is not assignable to type 'number'.
Type 'undefined' is not assignable to type 'number'.The Expect line names the contract that broke, and the usage line shows what a consumer would see. Keeping a few realistic usage lines alongside the equality checks is worthwhile because they read like documentation and fail with the same message your users would get.
Making it part of the build
Type tests are only as good as the step that compiles them. A common layout is a type-tests/ folder (or *.test-d.ts files) included by a tsconfig.typetest.json, plus a CI step:
npx tsc -p tsconfig.typetest.json --noEmitA few points to get right:
- Compile with the strictest settings your consumers might use. If the tests run without
strict, a regression that only appears understrictNullChecksslips through. - Import from the package entry point, not from source files, so you test what you actually publish.
- If you support several TypeScript versions, run the type tests against each one. Inference changes between releases.
You don’t have to hand-roll the helpers. Vitest ships expectTypeOf(value).toEqualTypeOf<T>() and checks it when run with --typecheck, and packages like tsd and expect-type do the same job. They wrap the same ideas, and knowing how Equal works lets you explain their output when an assertion fails in a confusing way.
Interview practice
Your team maintains createStore<S>(initial: S) in an internal package. A refactor changed store.get() from returning S to returning any, every unit test passed, and a consumer shipped a bug. How would you stop that happening again?
Answer
The runtime tests couldn’t see it, because S and any produce the same object. I’d add a type test file that the CI pipeline compiles with tsc --noEmit under strict, importing the store from the package’s public entry. In it, a positive assertion: create a store with { count: 0 } and write type _ = Expect<Equal<ReturnType<typeof store.get>, { count: number }>>. I’d use the generic-function Equal, not mutual extends, because the mutual version says any equals everything and would have passed this exact regression.
I’d add negative assertions too, such as // @ts-expect-error above store.set({ count: "1" }), so if the setter ever accepts anything, the unused directive fails the build. If the team already runs Vitest, expectTypeOf(store.get()).toEqualTypeOf<{ count: number }>() with vitest --typecheck is a fine substitute, as long as the CI job actually enables type checking.