Command Palette

Search for a command to run...

Hectal
PHASE 15Intermediate ~25 min· topic 3 of 6

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.

src/cart.tswhole filets
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.

src/cart.test.tswhole filets
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.

terminal (Vitest 5.0.3)whole filebash
$ 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.

src/cart.test-d.tswhole filets
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.

terminal (Vitest 5.0.3, --typecheck)whole filebash
$ 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.

terminalwhole filebash
$ 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. 1

    Make a test fail

    In the first example, change the last toBe(1) to toBe(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. 2

    Add a type test of your own

    In the second example, add Expect<Equal<Required<Fruit>["sweet"], boolean>> to Cases. Predict if it passes. Then add Expect<Equal<keyof Fruit, "name" | "sweet">> and check.

  3. 3

    Break the fake

    In the third example, add a second method close(): void to Mailer. The line const fake: Mailer = { send } now fails. The lazy as unknown as fake would have kept passing.

Code & diagrams

test() and expect() written by hand

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 typed
Type tests: Expect<Equal<A, B>> and @ts-expect-error

Equal 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 pear
A mock typed against the real interface

fn 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' ] ]
42

Break 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");.

terminal
$ Types panel
── what you'll see ──
index.ts(32,27): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

Break #2

Break a type test

In the second example, change Expect<Equal<Readonly<Fruit>["name"], string>> to ... number>>.

terminal
$ Types panel
── what you'll see ──
index.ts(20,10): error TS2344: Type 'false' does not satisfy the constraint 'true'.

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 };.

terminal
$ Types panel
── what you'll see ──
index.ts(23,1): error TS2578: Unused '@ts-expect-error' directive.

Break #4

Let a mock lose its signature

In the third example, change Promise.resolve(to.endsWith("@hectal.in")) to to.length.

terminal
$ Types panel
── what you'll see ──
index.ts(22,24): error TS2322: Type '((to: string, subject: string) => number) & { calls: [to: string, subject: string][]; }' is not assignable to type '(to: string, subject: string) => Promise<boolean>'.
Type 'number' is not assignable to type 'Promise<boolean>'.

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 treats any, readonly modifiers and intersections strictly, which a plain A extends B check would not. It is the helper in the type-challenges project and in most libraries' type tests; Vitest's expectTypeOf uses a similar mechanism and prints the readable message Expected 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.ts files so ordinary test runs stay fast, and include them in CI.

  • ▸

    Vitest's --typecheck runs tsc underneath 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 with tsc --noEmit where every @ts-expect-error is a negative test.

  • ▸

    Vitest 5.0.3 requires Node ^22.12.0 || ^24.0.0 || >=26.0.0 (from npm view vitest engines), and @types/node ^22.0.0 || >=24.0.0 as an optional peer.

Remember this

  1. 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. 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 type A extends B ? X : Y picks a type, as Equal below does. Topic 9.2: a bare T in a conditional spreads over a union, and [T] stops that.

  3. 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 .ts files out of the box. You write test("name", () => { expect(actual).toBe(expected) }); if expect finds a difference, it throws, and the runner reports a failure. The first live example writes test and expect by hand in twenty lines, so there is no magic left.

  4. 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 --noEmit if the test files are included in your tsconfig.json, which is a reason to include them. Typing can make a matcher stricter: in the hand-written expect of the first live example, toBe accepts only the same type T as the value, so expect(total([1])).toBe("1") is an error. Vitest's own toBe is looser: in a scratch run (Vitest 5.0.3, tsc 7.0.2) both toBe("1") and toEqual("1") on a number type-checked without an error. That gap is one reason type tests with expectTypeOf exist.

  5. 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 any or as unknown as Mailer compiles, but the compiler has stopped checking it: rename a method in the real Mailer and 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 tiny fn helper like Vitest's vi.fn, which records calls and keeps the original parameter types.

  6. 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 giving vi.fn the real signature, or build the fake from the real type, over casting.

  7. 7

    Type tests check types as a program. The idea: write a type that is true when two types are identical, and a helper that only accepts true. If someone changes a type so it no longer matches, tsc fails, and no code needs to run at all. Vitest ships expectTypeOf for this (expectTypeOf(total).returns.toEqualTypeOf<number>()), and the TypeScript repo uses the same Expect<Equal<A, B>> trick you will build below. Test files for types are named *.test-d.ts and run with vitest --typecheck.

  8. 8

    // @ts-expect-error is 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. 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

01

Why does a Vitest test with a type error still run?

02

What is a type test, and how do Expect<Equal<A, B>> and @ts-expect-error work?

03

Why is as unknown as Mailer a risky way to write a mock, and what is better?

04

When would you write type tests, and when not?

Practice

01

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.)

02

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 --typecheck is 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 expect function 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.ts and vitest --typecheck are for.