Testing TypeScript
Tests and types are not rivals — they check different things, and a professional project needs both. Types prove that code matches its contract on every possible input; tests prove that a specific input produces the intended behavior. This lesson shows where each shines, how to type tests and mocks so the suite itself cannot rot, and how type-level tests close the gap between them.
What types prove versus what tests prove
A prover versus an experiment
A type system is a theorem prover running on every possible input; a test is an experiment on one input. Neither substitutes for the other:
| Question | Answered by types | Answered by tests |
|---|---|---|
Is price a number everywhere? | yes, exhaustively | only for tested paths |
Is applyDiscount(100) equal to 90? | impossible to express | yes — the point of unit tests |
| Does refactoring preserve behavior? | only the signatures | yes, if the suite is good |
| Is this state machine unreachable-state-free? | yes, with modeling | hard to enumerate |
How the suite changes under strict mode
What we learn: strong typing shifts what tests must cover: type errors disappear from the suite, and the tests that remain assert behavior, not shape. A TypeScript test suite full of "does the field exist" assertions is compensating for missing types.
Typing the suite itself
Test code is code — untyped helpers rot exactly like untyped production code. The examples use vitest (the same patterns apply to node:test, Jest, or any runner).
Tests that fail to compile instead of failing quietly
Purpose of the example: show the payoff of typing test helpers: a renamed production field breaks the build, not the test run three minutes later.
import { expect, test } from "vitest";
import { formatPrice } from "../src/pricing";
test("formats EUR prices with two decimals", () => {
// If `formatPrice` is (cents: number, currency: string) => string,
// this call is a compile error the moment the signature changes:
expect(formatPrice(1999, "EUR")).toBe("19.99 €");
});
test("rejects negative prices", () => {
// The type of the thrown value is checked too:
expect(() => formatPrice(-1, "EUR")).toThrow(RangeError);
});
What you should see: rename currency to locale in production: the editor flags every test call site instantly, before any test runs. In a JavaScript project the same rename fails only at runtime — and only for the cases a test happens to cover.
Typed test fixtures and factories
Purpose of the example: build a fixture factory that stays correct as User evolves — the fixture equivalent of the builder pattern.
interface User { id: string; name: string; role: "admin" | "viewer" }
// Partial + required id: every test declares only what it cares about,
// and the compiler still guarantees a complete User is produced.
function makeUser(overrides: Partial<Omit<User, "id">> = {}): User {
return { id: crypto.randomUUID(), name: "Test", role: "viewer", ...overrides };
}
test("admins can delete posts", () => {
const admin = makeUser({ role: "admin" }); // checked against User
expect(canDeletePosts(admin)).toBe(true);
});
What you should see: add a required email: string to User: the factory errors immediately, and fixing it repairs every test at once. A plain object literal in each test would fail one test file at a time, at runtime, in CI.
Mocks that cannot drift
Why hand-written mocks rot
Hand-written mocks are silent liabilities: production interfaces change, mocks keep the old shape, tests keep passing against fiction. Typing mocks against the real interface turns drift into a compile error.
satisfies: the mock under contract
Purpose of the example: a mock repository that is checked against the contract it imitates.
interface UserRepo {
getById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// `satisfies` checks the mock against UserRepo without widening its type:
const fakeRepo: UserRepo = {
async getById(id) { return id === "u_1" ? makeUser() : null; },
async save() { /* record call for assertions */ }
};
// Wrong shape is rejected here, in the mock — not in production:
// const badRepo: UserRepo = { getById: async () => 42 }; // compile error
What you should see: add a method to UserRepo — every mock missing it errors where the mock is defined. What we learn: satisfies (or annotating the mock with the interface type) makes mocks contracts under test rather than folklore; mocking libraries like Vitest's vi.fn() integrate with the same checking.
Type-level tests
When your types carry real logic (discriminated unions, mapped types, template literals), the types themselves deserve tests. Type-level assertions verify that a type is what you think — at compile time, with no runtime cost.
Asserting types with expectTypeOf
Purpose of the example: pin public type inference so a refactor that silently changes inferred types fails the build.
import { expectTypeOf, test } from "vitest";
import { parseTag } from "../src/tags";
test("parseTag narrows to valid tags", () => {
// Type-level assertion: checked by tsc, erased at runtime:
expectTypeOf(parseTag("audio")).toEqualTypeOf<Tag>();
// @ts-expect-error — invalid input must NOT compile; if a future
// refactor loosens the signature, this line turns the loosening
// into a visible failure:
// parseTag("bogus-tag");
});
What you should see: the file type-checks. Now change parseTag's return type to string: the test fails to compile — the type contract is under test. This is the core of libraries like expect-type and tsd.
Testing unreachable states: exhaustiveness
Purpose of the example: convert a whole bug class (forgetting a case in a switch) into a compile error.
type Shape =
| { kind: "circle"; r: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "rect": return s.w * s.h;
// No default: adding { kind: "triangle" } to Shape makes THIS
// function a compile error until it is handled. That is a test
// the compiler writes for you.
}
}
What you should see: add a third variant to the union — tsc --noEmit reports the function may return undefined (under noImplicitReturns) or that the switch is non-exhaustive. The discipline: never write default: in switches over discriminated unions.
What the compiler cannot catch
External data lies about its types
Be honest about the boundary, or the boundary gets discovered in production:
- External data lies about its types. A fetch response annotated
Orderis a promise, not a proof — only runtime validation (the errors lesson) or a test can check the wire format. Every integration with an API that has no types deserves a fixture test.
Behavior and timing need experiments
- Behavioral correctness. The types cannot say
applyDiscountcomputes 10% and not 15%. Property-based tests (fast-check) complement both. - Concurrency and timing. Race conditions, debounce timing, retry backoff — tests with fake timers, not types.
Practice: build the safety net
Write a type-level test suite
Take the state machine from the types lesson. Test its behavior (a unit test per transition) and its shape (expectTypeOf that next(state, "pay") returns only { status: "paid" }). Then break the machine's union on purpose and watch both layers catch it in different ways. What we learn: the layers are complementary — behavior tests catch logic drift, type tests catch contract drift.
Mutation-check the suite
Flip a comparison in production code (> to >=) and run the suite. If every test still passes, the suite is too weak — and no type system can fix that. Professional teams run automated mutation testing (StrykerJS) for exactly this audit.