Skip to main content
Back to Blog
Automation TutorialsTypeScript Quality Engineering · Part 7

Make TypeScript Type-Level Tests Earn Their Place

A type-level test earns its place when a type-only change could break a caller you cannot see. Using a typed API client whose endpoint map infers params from the path, this guide covers expectTypeOf and *.test-d.ts files under vitest --typecheck, why a plain vitest run skips them, an @ts-expect-error that stays green after the contract breaks, how a leaked any passes assertType and toExtend but not toEqualTypeOf, the type test that mirrors its implementation, and when not to write type tests at all.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Oct 10, 2026Updated Oct 14, 202616 min read
View AI-ready Markdown
Dark technical illustration over a faint dashed vertical grid. Five pills enter from the left edge: three green ones run level and then curve together into a tall dashed green gate holding a ringed green dot, while two orange ones between them stop at crossed-out orange rings well short of the gate. Above everything, a sixth orange pill starts a dashed orange arc that sweeps over the top of the gate and ends at another crossed-out ring. From the gate three green arrows fan out to three rounded green panels stacked on the right, each with a small dot and a different arrangement of green bars, and faint lines continue from each panel to arrowheads at the right edge. Below a thin rule, a separate grey lane runs across the full width with a small grey panel holding two grey bars.
Quick answer: Write a type test only for a promise a caller can see: what a public generic infers, which calls it refuses, and what a check narrows to. Put those assertions in *.test-d.ts files, assert inferred types with expectTypeOf(...).toEqualTypeOf<T>() so a leaked any fails, and keep every // @ts-expect-error on a one-line call that differs from a valid call in exactly one way. Run them with vitest run --typecheck, because a plain vitest run never looks at those files. Anything about what the code does with real data stays in ordinary tests.

A typed API client ships with a small, pleasant promise: client.get("/users/:id", { id }) resolves to a User, and a misspelt path is a compile error. Six months later someone adds an escape hatch for ad-hoc URLs. Every test stays green. The misspelt path now compiles, returns unknown, and the first person to notice is a consumer whose editor stopped complaining.

Nothing in the runtime suite could have caught that, because at runtime the old and new clients behave identically. The thing that regressed was the type signature, and the type signature is the product for anyone importing the package. That is the case a type-level test exists for, and it is narrower than what most type test suites cover.

Vitest calls this mode experimental. When type checking is enabled, Vitest 5.0.2 prints "Testing types with tsc and vue-tsc is an experimental feature" and asks you to pin its version, because breaking changes to it "might not follow SemVer". Pin an exact Vitest version in any project that relies on it. On 2026-09-30 the current releases were Vitest 5.0.2, whose expectTypeOf is re-exported from expect-type (dependency range ^1.4.0, current release 1.4.0), and TypeScript 7.0.2. Every example and every error below was produced with Vitest 5.0.2 using its default tsc checker against TypeScript 7.0.2 on Node.js 26.0.0. The Vitest documentation does not mention TypeScript 7 either way; that run is the only evidence here that the two work together.

What a consumer of the client can observe

Before writing any assertion, list what someone calling the code could notice if it changed. For a typed client that list is short:

  • The response type for each declared path.
  • Which calls are refused: an undeclared path, a missing parameter, an unknown parameter, a parameter passed to a path that takes none.
  • The parameter object inferred from the path template.
  • What a result narrows to after the caller checks it.

Everything else, such as the name of the conditional type that pulls :id out of a template or whether a helper returns an intersection or a flattened object, is an implementation detail. Pin it and the test fails on refactors no caller would notice, until the team learns to delete type tests unread.

An endpoint map with literal response types

Here is the client. The endpoint map is an interface from path literal to response type, and the parameters come from the path itself using template literal types:

// src/client.ts
export type User = { id: string; name: string; role: "admin" | "member" };
export type Order = { id: string; userId: string; totalCents: number };

export interface Endpoints {
  "/users": User[];
  "/users/:id": User;
  "/users/:id/orders/:orderId": Order;
}

export type Path = keyof Endpoints;

type ParamNames<P extends string> =
  P extends `${string}:${infer Name}/${infer Rest}`
    ? Name | ParamNames<`/${Rest}`>
    : P extends `${string}:${infer Name}`
      ? Name
      : never;

export type Params<P extends string> = { [K in ParamNames<P>]: string };

type Args<P extends Path> = [ParamNames<P>] extends [never]
  ? []
  : [params: Params<P>];

export function createClient(baseUrl: string, fetchImpl: typeof fetch = fetch) {
  return {
    async get<P extends Path>(path: P, ...args: Args<P>): Promise<Endpoints[P]> {
      const params: Record<string, string> = args[0] ?? {};
      const url = path.replace(/:(\w+)/g, (_, name: string) => encodeURIComponent(params[name]));
      const res = await fetchImpl(baseUrl + url);
      if (!res.ok) throw new HttpError(res.status, `GET ${url} failed with ${res.status}`);
      return res.json();
    },
  };
}

export class HttpError extends Error {
  constructor(readonly status: number, message: string) {
    super(message);
  }
}

export type ApiResult<P extends Path> =
  | { ok: true; data: Endpoints[P] }
  | { ok: false; status: number };

export async function tryGet<P extends Path>(
  client: ReturnType<typeof createClient>,
  path: P,
  ...args: Args<P>
): Promise<ApiResult<P>> {
  try {
    return { ok: true, data: await client.get(path, ...args) };
  } catch (e) {
    return { ok: false, status: e instanceof HttpError ? e.status : 0 };
  }
}

Two design choices matter for testing. Args<P> collapses to an empty tuple when a path has no parameters, so client.get("/users") takes one argument and client.get("/users/:id") demands two. And Params is exported deliberately, because callers use it to build links, which makes its shape public. ParamNames and Args are not exported and will not be asserted on directly.

Note also what the declared return type hides. res.json() gives back any, and Promise<Endpoints[P]> accepts it without a word. The client's types describe what the server is supposed to send. Whether it does is a runtime question, and the earlier article on validating at TypeScript's runtime boundary covers it.

Inference, refusal and narrowing as type tests

The Vitest guide states the file convention plainly: "By default all tests inside *.test-d.ts files are considered type tests". They are never executed. In the guide's words, Vitest "doesn't run these files, they are only statically analyzed by the compiler", which also means a test.each title is shown as written, not expanded.

// src/client.test-d.ts
import { describe, expectTypeOf, test } from "vitest";
import { createClient, type Order, type Params, type User } from "./client.js";

const client = createClient("https://api.example.test");

describe("get()", () => {
  test("each path resolves to the response type the map declares", () => {
    expectTypeOf(client.get("/users")).resolves.toEqualTypeOf<User[]>();
    expectTypeOf(client.get("/users/:id", { id: "u_1" })).resolves.toEqualTypeOf<User>();
    expectTypeOf(
      client.get("/users/:id/orders/:orderId", { id: "u_1", orderId: "o_9" }),
    ).resolves.toEqualTypeOf<Order>();
  });

  test("params are inferred from the path template", () => {
    expectTypeOf<Params<"/users/:id/orders/:orderId">>().toEqualTypeOf<{ id: string; orderId: string }>();
    expectTypeOf<Params<"/users">>().toEqualTypeOf<{}>();
  });

  test("calls the contract forbids", () => {
    // @ts-expect-error "/user" is not a declared path
    client.get("/user");
    // @ts-expect-error "/users/:id" needs an id
    client.get("/users/:id");
    // @ts-expect-error "userId" is not a parameter of "/users/:id"
    client.get("/users/:id", { userId: "u_1" });
    // @ts-expect-error "/users" takes no params
    client.get("/users", { id: "u_1" });
  });
});

.resolves unwraps the promise before comparing, so there is no await and nothing is fetched. The expected types are written as literals, User[] and { id: string; orderId: string }, not computed from the code under test; that difference comes back later.

The refusal cases rely on @ts-expect-error, added in TypeScript 3.9. The release notes give the rule that makes it usable as an assertion: when a line is preceded by the comment, TypeScript suppresses the error, "but if there's no error, TypeScript will report that // @ts-expect-error wasn't necessary", as Unused '@ts-expect-error' directive. So a refusal test fails the moment the call starts compiling. The same notes recommend it over @ts-ignore when "you're writing test code where you actually want the type system to error on an operation".

Narrowing gets its own file because it tests a different export. The contract of ApiResult is that checking ok tells you which fields exist:

// src/result.test-d.ts
import { expectTypeOf, test } from "vitest";
import type { ApiResult, User } from "./client.js";

test("checking ok narrows the result", () => {
  const result = {} as ApiResult<"/users/:id">;
  if (result.ok) {
    expectTypeOf(result.data).toEqualTypeOf<User>();
  } else {
    expectTypeOf(result).toEqualTypeOf<{ ok: false; status: number }>();
    expectTypeOf(result).not.toHaveProperty("data");
  }
});

The cast is harmless: the file never runs. Flatten the union to { ok: boolean; data?: Endpoints[P]; status?: number }, a common "simplification", and all three assertions fail: data becomes User | undefined inside the if, and the else branch still has a data property.

Wiring --typecheck into the pipeline

Type checking is off by default: typecheck.enabled defaults to false. Run a project containing only these files through a plain vitest run and it stops with No test files found, exiting with code 1, because the default runtime include pattern is **/*.{test,spec}.?(c|m)[jt]s?(x) and a .test-d.ts name does not match it. In a project that also has runtime tests, the type tests are skipped with no message at all.

{
  "scripts": {
    "test": "vitest run --typecheck",
    "test:types": "vitest run --typecheck.only"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "vitest": "5.0.2"
  }
}

--typecheck runs the type tests alongside the runtime ones. --typecheck.only runs just the type tests, which is quicker when iterating on a signature. The type test glob, typecheck.include, defaults to ['**/*.{test,spec}-d.?(c|m)[jt]s?(x)'].

Two defaults change what a green run means. First, typecheck.ignoreSourceErrors is false, so a type error anywhere in the source fails the suite, not only errors in test files. Because the run uses tsc --noEmit under the hood, the Testing Types guide says you can remove a separate type-check script from the pipeline. Keep yours if the build uses a different tsconfig from the one Vitest picks: typecheck.tsconfig defaults to the closest tsconfig.json. Second, typecheck.checker defaults to tsc and accepts vue-tsc or "a path to custom binary or command name that produces the same output as tsc --noEmit --pretty false". Vitest parses that text output, which is why the docs describe the checker by its output format rather than its implementation.

An expected error that proves nothing

@ts-expect-error takes no error code. Any error on the next line satisfies it. That is the whole trap, and it shows up in three forms.

The first is the regression from the opening. Someone adds an overload for ad-hoc URLs:

get: get as {
  <P extends Path>(path: P, ...args: Args<P>): Promise<Endpoints[P]>;
  (path: string): Promise<unknown>;
},

Compare two refusal tests written against it. The loose one packs two mistakes into one call:

// Passes after the regression: the second argument still errors
// @ts-expect-error "/user" is not a declared path
client.get("/user", { id: "u_1" });

// Fails after the regression, as it should
// @ts-expect-error "/user" is not a declared path
client.get("/user");

The new overload accepts any string but only one argument, so the loose call still errors, on the argument count. The directive is satisfied and the test stays green while the promise it names has been broken. The minimal call, one character away from a valid client.get("/users"), is what catches it:

src/client.test-d.ts(21,5): error TS2578: Unused '@ts-expect-error' directive.
src/client.test-d.ts(23,5): error TS2578: Unused '@ts-expect-error' directive.

The second line is a bonus: client.get("/users/:id") with no parameters also compiles through the new overload, and that test fails too. One loosened signature, two broken promises, and only minimal calls see both. The rule to write down: every rejected call should differ from an accepted call in exactly one way, and the comment should name that one way.

The second form is a typo. The Vitest guide shows it with assertType<string>(answr): the misspelt name is itself a compile error, the directive is satisfied, and the test passes while checking nothing. The guide's fix is to also add the type test files to the runtime test.include, so the misspelling fails with a ReferenceError when executed. Before doing that, check what those files would do if run: the refusal tests above would call the real fetch against the base URL. Pass a stub fetchImpl to createClient first.

The third is layout. The directive covers the following line only. Format a refused call across several lines and the error can land on a line the directive does not reach:

// @ts-expect-error "userId" is not a parameter of "/users/:id"
client.get("/users/:id", {
  userId: "u_1",
});
src/ml.test-d.ts(5,3): error TS2578: Unused '@ts-expect-error' directive.
src/ml.test-d.ts(7,5): error TS2353: Object literal may only specify known properties, and 'userId' does not exist in type 'Params<"/users/:id">'.

This failure is at least loud. Keep refused calls on one line anyway. If a formatter would wrap one, shorten the call rather than letting it split.

An any that satisfies every loose matcher

Delete the explicit return type from get, the kind of cleanup that looks tidy in review. The function now returns whatever res.json() returns, which is Promise<any>. Here is how four assertions respond:

// All three still pass: any is assignable to User[]
assertType<Promise<User[]>>(client.get("/users"));
expectTypeOf(client.get("/users")).resolves.toExtend<User[]>();
const users: User[] = await client.get("/users");

// Fails: any is not identical to User[]
expectTypeOf(client.get("/users")).resolves.toEqualTypeOf<User[]>();

assertType is declared in Vitest as <T>(value: T): void with an empty body, so it is an assignability check and nothing more. toExtend is also an assignability check. any passes both. Only the equality matcher rejects it, with a terser error than its usual "Expected: …, Actual: …" message:

src/client.test-d.ts(8,63): error TS2344: Type 'User[]' does not satisfy the constraint 'never'.

When you see constraint 'never' from toEqualTypeOf, suspect any on the actual side first. The expect-type README documents the related behaviours: .toEqualTypeOf "distinguishes between deeply-nested any and unknown properties", the toBe… matchers "protect against any", and .not.toBeAny() guards a return you have just given a real type.

That makes the choice of matcher a decision rather than taste. For inferred results callers depend on, use toEqualTypeOf. Use toExtend only for real "is-a" questions, such as "is this handler assignable to the framework's type". For an object checked against a subset of its keys, Vitest's docs recommend toMatchObjectType, which is "stricter than toExtend" and more likely to catch issues "like readonly properties", with one caveat: it "only works with plain object types" and fails on unions. If your suite still uses toMatchTypeOf, it has been deprecated "since expect-type v1.2.0"; the README says .toExtend is "identical" to it and that there are "no foreseeable plans to remove" it, so migrating is a search and replace with no behaviour change.

A type test that mirrors the implementation

The expected side of an assertion has to come from somewhere other than the code under test. This one does not:

// Passes whatever ParamNames computes
type P = "/users/:id/orders/:orderId";
expectTypeOf<Params<P>>().toEqualTypeOf<{ [K in ParamNames<P>]: string }>();

Break ParamNames so it stops recursing after the first parameter, and this test still passes, because both sides now agree on the wrong answer. The literal version from earlier fails, and so does the response-type test for the orders path, because orderId is no longer an accepted key. The mirrored test also had to export ParamNames just to be written, which turns a private helper into something a later refactor must keep.

If writing the expected type needs the same conditional-type machinery as the implementation, you are restating code, not testing it. Write out the literal answer for two or three representative inputs instead.

The runtime half in client.test.ts

Type tests cannot tell you that a/b is encoded as a%2Fb, or that a 404 becomes { ok: false, status: 404 }. Those claims go in an ordinary test file that runs in the same vitest run --typecheck:

// src/client.test.ts
import { expect, test, vi } from "vitest";
import { createClient, tryGet } from "./client.js";

test("fills and encodes path params", async () => {
  const fetchImpl = vi.fn(async () => Response.json({ id: "a/b", name: "Ada", role: "admin" }));
  const client = createClient("https://api.example.test", fetchImpl);

  await client.get("/users/:id", { id: "a/b" });

  expect(fetchImpl).toHaveBeenCalledWith("https://api.example.test/users/a%2Fb");
});

test("a 404 becomes a failed result carrying the status", async () => {
  const fetchImpl = vi.fn(async () => new Response(null, { status: 404 }));
  const client = createClient("https://api.example.test", fetchImpl);

  await expect(tryGet(client, "/users/:id", { id: "missing" })).resolves.toEqual({ ok: false, status: 404 });
});

Neither file can do the other's job. The narrowing test proves tryGet is typed as a discriminated union; only the runtime test proves it actually returns the failure branch on a 404. And neither proves the server sends a User. If the response shapes come from captured payloads, the JSON Schema Generator gives you a starting schema for the runtime check that the type map cannot perform.

When a type test costs more than it catches

Type tests are cheap to run and expensive to maintain, because they fail on changes that are harmless to callers. Skip them when:

  • The code is application code with no external consumers. The call sites are the type test; tsc already fails when a change breaks one of them.
  • The type is an annotation, not an inference. Asserting that a function annotated to return User returns User checks the compiler, not you.
  • The assertion pins an inferred shape nobody chose, such as the exact printed form of an intersection. expect-type's own README lists intersections as a limitation, where equality can fail "even when they are effectively identical".

They earn their place on library boundaries and on shared internal packages whose generics other teams call: a client like this one, a typed event emitter, a form builder that infers field names, a schema helper. The test is whether a type-only change could break a caller that you cannot see from your own repository.

An assistant can list a package's exported generics and draft accepted and refused calls for each, which saves typing. It cannot decide which inferred behaviour is a promise. Whether Params<"/users"> should be {} or never is an API decision for the maintainers, and a generated test that pins whichever one the code happens to produce makes that decision by accident.

Questions about type tests in Vitest

Why not use .not.toBeCallableWith for the refused calls?

Because it does not handle generic functions well, and this client is one. The expect-type README says toBeCallableWith "will likely fail if you try to use it with a generic function or an overload". With the client above, expectTypeOf(client.get).toBeCallableWith("/users/:id", { id: "x" }) fails: the generic is resolved against the whole Path union, so the checker demands an orderId too. Calling the function directly under @ts-expect-error tests what a user would actually write.

Does expectTypeOf do anything if the file does run?

No. Vitest's API page warns that "during runtime this function doesn't do anything", and expectTypeOf in Vitest 5.0.2 is a direct re-export of expect-type, whose README says neither it nor the chained functions "have any meaningful runtime behaviour". An expectTypeOf line in a regular .test.ts file is only checked if tsc or --typecheck covers that file.

Pin one public generic with type tests today

Pick the exported generic in your codebase with the most callers outside its own package. Write down what those callers can observe, then create one .test-d.ts file with an equality assertion for each inferred result, a one-line @ts-expect-error for each refused call, and a narrowing test if it returns a union. Add --typecheck to the test script and pin Vitest to an exact version.

Then capture the output of three deliberate breakages, reverting each one afterwards: delete the function's explicit return type and confirm an equality assertion fails; add a (path: string) overload and confirm a refusal test reports TS2578; run plain vitest run and confirm the type tests do not appear in its output. If the first two stay green, the tests are checking assignability or catching the wrong error, and the third tells you whether your CI command was ever running them.

Primary references

  • Vitest — Testing Types. That *.test-d.ts files are type tests by default, that Vitest "calls tsc or vue-tsc" and "doesn't run these files", that test.each names are not evaluated, that overlapping include and typecheck.include have been reported separately since Vitest 2.1, the answr typo example and the test.include remedy, the error-reading guidance, and that the typecheck run uses tsc --noEmit "so you can remove" a separate script.
  • Vitest — expectTypeOf. That the function does nothing at runtime; the semantics of toEqualTypeOf, toExtend, .resolves, toHaveProperty and toBeCallableWith; toMatchObjectType being "stricter than toExtend" and limited to plain object types; and toMatchTypeOf deprecated "since expect-type v1.2.0".
  • Vitest — typecheck config. Defaults for typecheck.enabled (false), typecheck.only, typecheck.checker (tsc, or a binary matching tsc --noEmit --pretty false output), typecheck.include and typecheck.ignoreSourceErrors (false).
  • Vitest v5.0.2 — typecheck/expectTypeOf.ts, with packages/vitest/package.json. That Vitest's expectTypeOf is re-exported from expect-type, and that Vitest 5.0.2 depends on expect-type ^1.4.0.
  • Vitest v5.0.2 — typecheck/assertType.ts. That assertType is declared as <T>(value: T): void with an empty body, so it checks assignability only.
  • Vitest v5.0.2 — node/config/resolveConfig.ts. The warning printed when type checking is enabled, that it is "an experimental feature" whose breaking changes "might not follow SemVer", with the advice to pin Vitest's version.
  • expect-type v1.4.0 README. That assertions have no runtime behaviour; that .toEqualTypeOf distinguishes nested any from unknown and the toBe… matchers "protect against any"; why .toMatchTypeOf is deprecated and that .toExtend is identical; and the listed limitations for intersections and for toBeCallableWith on generic or overloaded functions.
  • TypeScript 3.9 release notes — // @ts-expect-error Comments. That the directive suppresses the error on the following line and that an error-free line produces Unused '@ts-expect-error' directive, plus the guidance to prefer it in test code.
  • TypeScript v7.0.2 release. The current TypeScript release at the time of checking, published 2026-08-20, the compiler every example and error message above was produced with.

TypeScript Quality Engineering

Get part 8: Validate AI Agent Tool Calls in TypeScript: Schema, Authorization, Approval

It is live now: read part 8. The form below subscribes you to The Automation Brief, the site newsletter.

No spam, no sending cadence promised. Unsubscribe any time.