Topic 15.3
Testing TypeScript Code
In one line
Tests run your code and compare results; type tests ask the compiler whether a type is exactly what you meant. TypeScript lets you test both, and keeps your mocks honest so they cannot drift away from the real thing.
Think of it like this
A bakery checks every cake twice. A taster eats a slice and says "yes, it tastes right" (that is a test: run it, look at the result). An inspector reads the recipe card and says "this card promises eggs, and the cake has none" (that is a type test: the promise itself is checked). A good bakery needs both, because a cake can taste fine today and still break its promise tomorrow.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Unit test
- A small program that runs one piece of your code with known input and checks the result.
- Test runner
- The tool that finds your tests, runs them and reports passes and failures. Vitest is one.
- Assertion
- A check inside a test, like expect(x).toBe(3). If it is false, the test fails.
- Mock
- A fake stand-in for a real dependency (an email sender, a database), used so tests are fast and safe.
- Type test
- A test about a type, not a value. The compiler runs it: no code is executed.
- expectTypeOf
- Vitest's helper for type tests, e.g. expectTypeOf(fn).returns.toEqualTypeOf<number>().
- @ts-expect-error
- A comment saying the next line must be a type error. If it isn't, the comment is reported as an error.
- Equal<A, B>
- A hand-written type that is true only when A and B are exactly the same type.
- --typecheck
- A Vitest flag that also runs the type tests in *.test-d.ts files.
Step by step
01A function to test
We test the same small function throughout: total adds up a list of prices. In a Vite project the code lives in src/cart.ts and the tests next to it.
export function total(prices: number[]): number {
return prices.reduce((a, b) => a + b, 0);
}02A runtime test and a typed mock with Vitest
test and expect are imported from vitest. vi.fn<(msg: string) => void>() makes a mock whose call signature is the type argument.
The call log("hello") is checked against that signature. toHaveBeenCalledWith is not: in a scratch run, expect(log).toHaveBeenCalledWith(42) type-checked without any error, so a wrong expected argument is only caught when the test runs.
import { expect, test, vi } from "vitest";
import { total } from "./cart";
test("adds prices", () => {
expect(total([10, 20, 30])).toBe(60);
});
test("a typed mock", () => {
const log = vi.fn<(msg: string) => void>();
log("hello");
expect(log).toHaveBeenCalledWith("hello");
});03Run it
vitest run runs once and exits (without run it stays in watch mode). This is the real output of Vitest 5.0.3 on the two files, on Node 24.
$ npx vitest run
RUN v5.0.3 ~/my-app
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 14:56:41
Duration 424ms (transform 52%, import 30%, worker 10%, tests 7%)04Type tests with expectTypeOf
A type test file is named *.test-d.ts and nothing in it runs: Vitest only asks tsc about it. The function expectTypeOf(total).returns.toEqualTypeOf<number>() fails to compile if total stops returning exactly number.
Vitest's docs still call this mode experimental: the run prints "Testing types with tsc and vue-tsc is an experimental feature" and asks you to pin the Vitest version.
import { expectTypeOf, test } from "vitest";
import { total } from "./cart";
test("total types", () => {
expectTypeOf(total).returns.toEqualTypeOf<number>();
expectTypeOf(total).parameter(0).toEqualTypeOf<number[]>();
});05Watch a type test fail
Change toEqualTypeOf<number> to toEqualTypeOf<string> and run npx vitest run --typecheck. The failure message names both types: Expected string, Actual number. This is real output.
$ npx vitest run --typecheck
❯ TS src/cart.test-d.ts (1 test | 1 failed)
× total types
FAIL src/cart.test-d.ts:4 > total types
TypeCheckError: Type 'string' does not satisfy the constraint '"Expected string, Actual number"'.
❯ src/cart.test-d.ts:5:45
5| expectTypeOf(total).returns.toEqualTypeOf<string>();
| ^
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 2 passed (3)06Types in a test are still checked by tsc
If a normal test file passes the wrong argument to a typed mock, Vitest's plain run does not care, and the test only fails at runtime or not at all. tsc catches it. Change log("hello") to log(42) in cart.test.ts.
$ npx tsc --noEmit -p .
src/cart.test.ts(10,7): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.Try it yourself
- 1
Make a test fail
In the first example, change the last
toBe(1)totoBe(2). The compiler is happy (both are numbers). Predict the console:FAIL the matcher is typed - expected 2, got 1.Now change it to
toBe("1")and compare. One failure is found by the runtime, the other by the compiler before running. - 2
Add a type test of your own
In the second example, add
Expect<Equal<Required<Fruit>["sweet"], boolean>>toCases. Predict if it passes. Then addExpect<Equal<keyof Fruit, "name" | "sweet">>and check. - 3
Break the fake
In the third example, add a second method
close(): voidtoMailer. The lineconst fake: Mailer = { send }now fails. The lazyas unknown asfake would have kept passing.
Code & diagrams
expect<T> remembers the type of the actual value, so toBe only accepts the same type. Change the last toBe(1) to toBe("1"): the compiler rejects it, and the runner shows the surprising text 'expected 1, got 1' (a number and a string that look the same).
Console
(nothing was printed)Expected console output
PASS adds prices
PASS empty cart is zero
PASS the matcher is typedEqual compares two types by asking whether they behave the same in a generic position, which is stricter than 'assignable' (the same trick Vitest and the TypeScript test suite use). Each Expect line is a test. Change string to number in the Readonly line, or give bad its sweet field, and read the errors.
Console
(nothing was printed)Expected console output
apple pearfn records every call in calls, with the parameter types kept (a labelled tuple, Topic 5.2: a fixed-length array whose positions have names and types). const fake: Mailer = { send } is checked against the real interface. Change the Promise.resolve(...) in send to to.length and read the error. The last two lines show the as unknown as trick: it compiles and returns 42 where the interface promised a Promise<boolean>.
Console
(nothing was printed)Expected console output
sent
failed
[ [ 'asha@hectal.in', 'Welcome' ], [ 'ravi@example.com', 'Welcome' ] ]
42Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Pass the wrong type to the matcher
In the first example, change the last assertion to expect(total([1])).toBe("1");.
Break #2
Break a type test
In the second example, change Expect<Equal<Readonly<Fruit>["name"], string>> to ... number>>.
Break #3
A @ts-expect-error that expects nothing
In the second example, change const bad: Fruit = { name: "pear" }; to const bad: Fruit = { name: "pear", sweet: false };.
Break #4
Let a mock lose its signature
In the third example, change Promise.resolve(to.endsWith("@hectal.in")) to to.length.
Myth vs fact
Myth
Vitest fails a test when the types are wrong.
Fact
A plain vitest run erases types and ignores type errors. Run tsc --noEmit over your test files too, or use vitest --typecheck for *.test-d.ts files.
Myth
100% test coverage means the types are tested.
Fact
Coverage counts executed lines. A type test is about what the compiler accepts and rejects, and nothing is executed.
Myth
as any is a harmless way to build a fake.
Fact
It turns checking off for that value. When the real interface changes, the fake still compiles and the test stays green while production breaks.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Equal<A, B>as written relies on how the compiler compares two conditional types for identity. It treatsany,readonlymodifiers and intersections strictly, which a plainA extends Bcheck would not. It is the helper in the type-challenges project and in most libraries' type tests; Vitest'sexpectTypeOfuses a similar mechanism and prints the readable messageExpected string, Actual number. - ▸
Type tests can be slow in large libraries because each test forces the checker to resolve a type fully. Keep them in separate
*.test-d.tsfiles so ordinary test runs stay fast, and include them in CI. - ▸
Vitest's
--typecheckrunstscunderneath and says in its output that it is experimental. In its output the pass for the type tests is counted separately ("Type Errors no errors"). For libraries, a common alternative is a folder of small TypeScript files compiled withtsc --noEmitwhere every@ts-expect-erroris a negative test. - ▸
Vitest 5.0.3 requires Node
^22.12.0 || ^24.0.0 || >=26.0.0(fromnpm view vitest engines), and@types/node^22.0.0 || >=24.0.0as an optional peer.
Remember this
- 1
The problem this solves: you change some code, and you need to know whether it still works and whether its types still say what you meant. Checking by hand does not scale. Tests do it automatically, every time. And because types can be wrong in ways no test ever runs, there is a second kind of test, for the types themselves.
- 2
Recall before you start. Topic 7.2: a generic function like
expect<T>remembers the type of what you give it. Topic 9.1: a conditional typeA extends B ? X : Ypicks a type, asEqualbelow does. Topic 9.2: a bareTin a conditional spreads over a union, and[T]stops that. - 3
A unit test is a small program that calls one piece of your code with known input and compares the result with what you expect. Vitest is a test runner that works well with Vite and understands
.tsfiles out of the box. You writetest("name", () => { expect(actual).toBe(expected) }); ifexpectfinds a difference, it throws, and the runner reports a failure. The first live example writestestandexpectby hand in twenty lines, so there is no magic left. - 4
Vitest, like Vite, erases types and does not check them (Topic 15.1). A test file with a type error still runs. Type errors in tests are caught by
tsc --noEmitif the test files are included in yourtsconfig.json, which is a reason to include them. Typing can make a matcher stricter: in the hand-writtenexpectof the first live example,toBeaccepts only the same typeTas the value, soexpect(total([1])).toBe("1")is an error. Vitest's owntoBeis looser: in a scratch run (Vitest 5.0.3,tsc7.0.2) bothtoBe("1")andtoEqual("1")on a number type-checked without an error. That gap is one reason type tests withexpectTypeOfexist. - 5
Mocks are fake stand-ins for real things, such as an email service that would send real emails. A fake that is typed
as anyoras unknown as Mailercompiles, but the compiler has stopped checking it: rename a method in the realMailerand the fake keeps passing while the real code breaks. A fake typed against the real interface (const fake: Mailer = { send }) breaks the moment the interface changes. The third live example builds a tinyfnhelper like Vitest'svi.fn, which records calls and keeps the original parameter types. - 6
Vitest's own
vi.fn<(msg: string) => void>()takes a function type as its type argument, and calling it with the wrong argument is a compile error (shown in the terminal run below). Prefer givingvi.fnthe real signature, or build the fake from the real type, over casting. - 7
Type tests check types as a program. The idea: write a type that is
truewhen two types are identical, and a helper that only acceptstrue. If someone changes a type so it no longer matches,tscfails, and no code needs to run at all. Vitest shipsexpectTypeOffor this (expectTypeOf(total).returns.toEqualTypeOf<number>()), and the TypeScript repo uses the sameExpect<Equal<A, B>>trick you will build below. Test files for types are named*.test-d.tsand run withvitest --typecheck. - 8
// @ts-expect-erroris the other half of type testing: it says "the next line must be a type error". If the line is fine, the directive itself becomes the error (TS2578: Unused '@ts-expect-error' directive). That makes it the right tool for proving that bad input is rejected, and the reason it is preferred over// @ts-ignore, which stays silent whether or not there is an error (Topic 10.6). - 9
What should you type-test? Public types of a library, generic helpers, anything where a wrong inferred type would silently reach users. Ordinary application code needs ordinary tests. Do not test that TypeScript works; test that your types say what you intend.
Explain it without notes
Why does a Vitest test with a type error still run?
What is a type test, and how do Expect<Equal<A, B>> and @ts-expect-error work?
Why is as unknown as Mailer a risky way to write a mock, and what is better?
When would you write type tests, and when not?
Practice
Write type IsString<T> = ... and three Expect<Equal<...>> lines that prove IsString<"a"> is true, IsString<1> is false and IsString<string | number> is false. (Use the Equal and Expect helpers from the example.)
Write a @ts-expect-error negative test showing that makeUser({ name: 1 }) is rejected for function makeUser(input: { name: string }): { name: string }.
Trade-offs
- ↔
Type tests add files and a little compile time, and
vitest --typecheckis still marked experimental. For libraries they are cheap insurance. For apps they are rarely worth it. - ↔
Fully typed fakes take more effort than casts, but they are the only fakes that keep protecting you after the real interface changes.
Done when you can
I can explain why a test runner can pass code that has type errors.
I can write an
expectfunction that keeps the type of the actual value.I can write
Expect<Equal<A, B>>and read the error when it fails.I can prove bad input is rejected with
@ts-expect-error.I can type a mock against the real interface instead of casting.
I know what
*.test-d.tsandvitest --typecheckare for.