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

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-10T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/typescript-type-level-tests-contract-value

- Tags: typescript, vitest, type-testing, expect-type, api-clients, unit-testing

**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](/blog/typescript-runtime-boundary-validation-testing) 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](/tools/json-schema) 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](https://vitest.dev/guide/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](https://vitest.dev/api/expect-typeof). 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](https://vitest.dev/config/typecheck). 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](https://github.com/vitest-dev/vitest/blob/v5.0.2/packages/vitest/src/typecheck/expectTypeOf.ts), with [packages/vitest/package.json](https://github.com/vitest-dev/vitest/blob/v5.0.2/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](https://github.com/vitest-dev/vitest/blob/v5.0.2/packages/vitest/src/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](https://github.com/vitest-dev/vitest/blob/v5.0.2/packages/vitest/src/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](https://github.com/mmkal/expect-type/blob/v1.4.0/README.md). 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](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-9.html). 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](https://github.com/microsoft/TypeScript/releases/tag/v7.0.2). The current TypeScript release at the time of checking, published 2026-08-20, the compiler every example and error message above was produced with.

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Make TypeScript Type-Level Tests Earn Their Place](https://automationtester.in/blog/automation-tutorials/typescript-type-level-tests-contract-value) by Shashank Rawlani.
