{"title":"Make TypeScript Type-Level Tests Earn Their Place","excerpt":"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.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/typescript-type-level-tests-contract-value","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["typescript","vitest","type-testing","expect-type","api-clients","unit-testing"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-10T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/typescript-type-level-tests-contract-value.webp","alt":"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."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> 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 <code class=\"language-text\">*.test-d.ts</code> files, assert inferred types with <code class=\"language-ts\">expectTypeOf(...).toEqualTypeOf&lt;T&gt;()</code> so a leaked <code class=\"language-ts\">any</code> fails, and keep every <code class=\"language-ts\">// @ts-expect-error</code> on a one-line call that differs from a valid call in exactly one way. Run them with <code class=\"language-bash\">vitest run --typecheck</code>, because a plain <code class=\"language-bash\">vitest run</code> never looks at those files. Anything about what the code does with real data stays in ordinary tests.</div>\n\n<p>A typed API client ships with a small, pleasant promise: <code class=\"language-ts\">client.get(\"/users/:id\", { id })</code> resolves to a <code class=\"language-ts\">User</code>, 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 <code class=\"language-ts\">unknown</code>, and the first person to notice is a consumer whose editor stopped complaining.</p>\n\n<p>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.</p>\n\n<div class=\"callout callout-warning\"><strong>Vitest calls this mode experimental.</strong> 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 <code class=\"language-ts\">expectTypeOf</code> is re-exported from expect-type (dependency range <code class=\"language-text\">^1.4.0</code>, 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 <code class=\"language-text\">tsc</code> 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.</div>\n\n<h2 id=\"what-a-consumer-of-the-client-can-observe\">What a consumer of the client can observe</h2>\n\n<p>Before writing any assertion, list what someone calling the code could notice if it changed. For a typed client that list is short:</p>\n\n<ul>\n<li>The response type for each declared path.</li>\n<li>Which calls are refused: an undeclared path, a missing parameter, an unknown parameter, a parameter passed to a path that takes none.</li>\n<li>The parameter object inferred from the path template.</li>\n<li>What a result narrows to after the caller checks it.</li>\n</ul>\n\n<p>Everything else, such as the name of the conditional type that pulls <code class=\"language-text\">:id</code> 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.</p>\n\n<h2 id=\"an-endpoint-map-with-literal-response-types\">An endpoint map with literal response types</h2>\n\n<p>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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// src/client.ts\nexport type User = { id: string; name: string; role: \"admin\" | \"member\" };\nexport type Order = { id: string; userId: string; totalCents: number };\n\nexport interface Endpoints {\n  \"/users\": User[];\n  \"/users/:id\": User;\n  \"/users/:id/orders/:orderId\": Order;\n}\n\nexport type Path = keyof Endpoints;\n\ntype ParamNames&lt;P extends string&gt; =\n  P extends `${string}:${infer Name}/${infer Rest}`\n    ? Name | ParamNames&lt;`/${Rest}`&gt;\n    : P extends `${string}:${infer Name}`\n      ? Name\n      : never;\n\nexport type Params&lt;P extends string&gt; = { [K in ParamNames&lt;P&gt;]: string };\n\ntype Args&lt;P extends Path&gt; = [ParamNames&lt;P&gt;] extends [never]\n  ? []\n  : [params: Params&lt;P&gt;];\n\nexport function createClient(baseUrl: string, fetchImpl: typeof fetch = fetch) {\n  return {\n    async get&lt;P extends Path&gt;(path: P, ...args: Args&lt;P&gt;): Promise&lt;Endpoints[P]&gt; {\n      const params: Record&lt;string, string&gt; = args[0] ?? {};\n      const url = path.replace(/:(\\w+)/g, (_, name: string) =&gt; encodeURIComponent(params[name]));\n      const res = await fetchImpl(baseUrl + url);\n      if (!res.ok) throw new HttpError(res.status, `GET ${url} failed with ${res.status}`);\n      return res.json();\n    },\n  };\n}\n\nexport class HttpError extends Error {\n  constructor(readonly status: number, message: string) {\n    super(message);\n  }\n}\n\nexport type ApiResult&lt;P extends Path&gt; =\n  | { ok: true; data: Endpoints[P] }\n  | { ok: false; status: number };\n\nexport async function tryGet&lt;P extends Path&gt;(\n  client: ReturnType&lt;typeof createClient&gt;,\n  path: P,\n  ...args: Args&lt;P&gt;\n): Promise&lt;ApiResult&lt;P&gt;&gt; {\n  try {\n    return { ok: true, data: await client.get(path, ...args) };\n  } catch (e) {\n    return { ok: false, status: e instanceof HttpError ? e.status : 0 };\n  }\n}</code></pre>\n\n<p>Two design choices matter for testing. <code class=\"language-ts\">Args&lt;P&gt;</code> collapses to an empty tuple when a path has no parameters, so <code class=\"language-ts\">client.get(\"/users\")</code> takes one argument and <code class=\"language-ts\">client.get(\"/users/:id\")</code> demands two. And <code class=\"language-ts\">Params</code> is exported deliberately, because callers use it to build links, which makes its shape public. <code class=\"language-ts\">ParamNames</code> and <code class=\"language-ts\">Args</code> are not exported and will not be asserted on directly.</p>\n\n<p>Note also what the declared return type hides. <code class=\"language-ts\">res.json()</code> gives back <code class=\"language-ts\">any</code>, and <code class=\"language-ts\">Promise&lt;Endpoints[P]&gt;</code> 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 <a href=\"/blog/typescript-runtime-boundary-validation-testing\">validating at TypeScript's runtime boundary</a> covers it.</p>\n\n<h2 id=\"inference-refusal-and-narrowing-as-type-tests\">Inference, refusal and narrowing as type tests</h2>\n\n<p>The Vitest guide states the file convention plainly: \"By default all tests inside <code class=\"language-text\">*.test-d.ts</code> 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 <code class=\"language-ts\">test.each</code> title is shown as written, not expanded.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// src/client.test-d.ts\nimport { describe, expectTypeOf, test } from \"vitest\";\nimport { createClient, type Order, type Params, type User } from \"./client.js\";\n\nconst client = createClient(\"https://api.example.test\");\n\ndescribe(\"get()\", () =&gt; {\n  test(\"each path resolves to the response type the map declares\", () =&gt; {\n    expectTypeOf(client.get(\"/users\")).resolves.toEqualTypeOf&lt;User[]&gt;();\n    expectTypeOf(client.get(\"/users/:id\", { id: \"u_1\" })).resolves.toEqualTypeOf&lt;User&gt;();\n    expectTypeOf(\n      client.get(\"/users/:id/orders/:orderId\", { id: \"u_1\", orderId: \"o_9\" }),\n    ).resolves.toEqualTypeOf&lt;Order&gt;();\n  });\n\n  test(\"params are inferred from the path template\", () =&gt; {\n    expectTypeOf&lt;Params&lt;\"/users/:id/orders/:orderId\"&gt;&gt;().toEqualTypeOf&lt;{ id: string; orderId: string }&gt;();\n    expectTypeOf&lt;Params&lt;\"/users\"&gt;&gt;().toEqualTypeOf&lt;{}&gt;();\n  });\n\n  test(\"calls the contract forbids\", () =&gt; {\n    // @ts-expect-error \"/user\" is not a declared path\n    client.get(\"/user\");\n    // @ts-expect-error \"/users/:id\" needs an id\n    client.get(\"/users/:id\");\n    // @ts-expect-error \"userId\" is not a parameter of \"/users/:id\"\n    client.get(\"/users/:id\", { userId: \"u_1\" });\n    // @ts-expect-error \"/users\" takes no params\n    client.get(\"/users\", { id: \"u_1\" });\n  });\n});</code></pre>\n\n<p><code class=\"language-ts\">.resolves</code> unwraps the promise before comparing, so there is no <code class=\"language-ts\">await</code> and nothing is fetched. The expected types are written as literals, <code class=\"language-ts\">User[]</code> and <code class=\"language-ts\">{ id: string; orderId: string }</code>, not computed from the code under test; that difference comes back later.</p>\n\n<p>The refusal cases rely on <code class=\"language-ts\">@ts-expect-error</code>, 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 <code class=\"language-ts\">// @ts-expect-error</code> wasn't necessary\", as <code class=\"language-text\">Unused '@ts-expect-error' directive</code>. So a refusal test fails the moment the call starts compiling. The same notes recommend it over <code class=\"language-ts\">@ts-ignore</code> when \"you're writing test code where you actually want the type system to error on an operation\".</p>\n\n<p>Narrowing gets its own file because it tests a different export. The contract of <code class=\"language-ts\">ApiResult</code> is that checking <code class=\"language-ts\">ok</code> tells you which fields exist:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// src/result.test-d.ts\nimport { expectTypeOf, test } from \"vitest\";\nimport type { ApiResult, User } from \"./client.js\";\n\ntest(\"checking ok narrows the result\", () =&gt; {\n  const result = {} as ApiResult&lt;\"/users/:id\"&gt;;\n  if (result.ok) {\n    expectTypeOf(result.data).toEqualTypeOf&lt;User&gt;();\n  } else {\n    expectTypeOf(result).toEqualTypeOf&lt;{ ok: false; status: number }&gt;();\n    expectTypeOf(result).not.toHaveProperty(\"data\");\n  }\n});</code></pre>\n\n<p>The cast is harmless: the file never runs. Flatten the union to <code class=\"language-ts\">{ ok: boolean; data?: Endpoints[P]; status?: number }</code>, a common \"simplification\", and all three assertions fail: <code class=\"language-ts\">data</code> becomes <code class=\"language-ts\">User | undefined</code> inside the <code class=\"language-ts\">if</code>, and the else branch still has a <code class=\"language-ts\">data</code> property.</p>\n\n<h2 id=\"wiring-vitest-typecheck-into-ci\">Wiring --typecheck into the pipeline</h2>\n\n<p>Type checking is off by default: <code class=\"language-text\">typecheck.enabled</code> defaults to <code class=\"language-text\">false</code>. Run a project containing only these files through a plain <code class=\"language-bash\">vitest run</code> and it stops with <code class=\"language-text\">No test files found, exiting with code 1</code>, because the default runtime include pattern is <code class=\"language-text\">**/*.{test,spec}.?(c|m)[jt]s?(x)</code> and a <code class=\"language-text\">.test-d.ts</code> name does not match it. In a project that also has runtime tests, the type tests are skipped with no message at all.</p>\n\n<pre class=\"language-json\"><code class=\"language-json\">{\n  \"scripts\": {\n    \"test\": \"vitest run --typecheck\",\n    \"test:types\": \"vitest run --typecheck.only\"\n  },\n  \"devDependencies\": {\n    \"typescript\": \"7.0.2\",\n    \"vitest\": \"5.0.2\"\n  }\n}</code></pre>\n\n<p><code class=\"language-bash\">--typecheck</code> runs the type tests alongside the runtime ones. <code class=\"language-bash\">--typecheck.only</code> runs just the type tests, which is quicker when iterating on a signature. The type test glob, <code class=\"language-text\">typecheck.include</code>, defaults to <code class=\"language-text\">['**/*.{test,spec}-d.?(c|m)[jt]s?(x)']</code>.</p>\n\n<p>Two defaults change what a green run means. First, <code class=\"language-text\">typecheck.ignoreSourceErrors</code> is <code class=\"language-text\">false</code>, so a type error anywhere in the source fails the suite, not only errors in test files. Because the run uses <code class=\"language-bash\">tsc --noEmit</code> 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 <code class=\"language-text\">tsconfig</code> from the one Vitest picks: <code class=\"language-text\">typecheck.tsconfig</code> defaults to the closest <code class=\"language-text\">tsconfig.json</code>. Second, <code class=\"language-text\">typecheck.checker</code> defaults to <code class=\"language-text\">tsc</code> and accepts <code class=\"language-text\">vue-tsc</code> or \"a path to custom binary or command name that produces the same output as <code class=\"language-bash\">tsc --noEmit --pretty false</code>\". Vitest parses that text output, which is why the docs describe the checker by its output format rather than its implementation.</p>\n\n<h2 id=\"an-expected-error-that-proves-nothing\">An expected error that proves nothing</h2>\n\n<p><code class=\"language-ts\">@ts-expect-error</code> takes no error code. Any error on the next line satisfies it. That is the whole trap, and it shows up in three forms.</p>\n\n<p>The first is the regression from the opening. Someone adds an overload for ad-hoc URLs:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">get: get as {\n  &lt;P extends Path&gt;(path: P, ...args: Args&lt;P&gt;): Promise&lt;Endpoints[P]&gt;;\n  (path: string): Promise&lt;unknown&gt;;\n},</code></pre>\n\n<p>Compare two refusal tests written against it. The loose one packs two mistakes into one call:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Passes after the regression: the second argument still errors\n// @ts-expect-error \"/user\" is not a declared path\nclient.get(\"/user\", { id: \"u_1\" });\n\n// Fails after the regression, as it should\n// @ts-expect-error \"/user\" is not a declared path\nclient.get(\"/user\");</code></pre>\n\n<p>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 <code class=\"language-ts\">client.get(\"/users\")</code>, is what catches it:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">src/client.test-d.ts(21,5): error TS2578: Unused '@ts-expect-error' directive.\nsrc/client.test-d.ts(23,5): error TS2578: Unused '@ts-expect-error' directive.</code></pre>\n\n<p>The second line is a bonus: <code class=\"language-ts\">client.get(\"/users/:id\")</code> 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.</p>\n\n<p>The second form is a typo. The Vitest guide shows it with <code class=\"language-ts\">assertType&lt;string&gt;(answr)</code>: 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 <code class=\"language-text\">test.include</code>, so the misspelling fails with a <code class=\"language-text\">ReferenceError</code> when executed. Before doing that, check what those files would do if run: the refusal tests above would call the real <code class=\"language-ts\">fetch</code> against the base URL. Pass a stub <code class=\"language-ts\">fetchImpl</code> to <code class=\"language-ts\">createClient</code> first.</p>\n\n<p>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:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// @ts-expect-error \"userId\" is not a parameter of \"/users/:id\"\nclient.get(\"/users/:id\", {\n  userId: \"u_1\",\n});</code></pre>\n\n<pre class=\"language-text\"><code class=\"language-text\">src/ml.test-d.ts(5,3): error TS2578: Unused '@ts-expect-error' directive.\nsrc/ml.test-d.ts(7,5): error TS2353: Object literal may only specify known properties, and 'userId' does not exist in type 'Params&lt;\"/users/:id\"&gt;'.</code></pre>\n\n<p>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.</p>\n\n<h2 id=\"any-satisfies-every-loose-matcher\">An any that satisfies every loose matcher</h2>\n\n<p>Delete the explicit return type from <code class=\"language-ts\">get</code>, the kind of cleanup that looks tidy in review. The function now returns whatever <code class=\"language-ts\">res.json()</code> returns, which is <code class=\"language-ts\">Promise&lt;any&gt;</code>. Here is how four assertions respond:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// All three still pass: any is assignable to User[]\nassertType&lt;Promise&lt;User[]&gt;&gt;(client.get(\"/users\"));\nexpectTypeOf(client.get(\"/users\")).resolves.toExtend&lt;User[]&gt;();\nconst users: User[] = await client.get(\"/users\");\n\n// Fails: any is not identical to User[]\nexpectTypeOf(client.get(\"/users\")).resolves.toEqualTypeOf&lt;User[]&gt;();</code></pre>\n\n<p><code class=\"language-ts\">assertType</code> is declared in Vitest as <code class=\"language-ts\">&lt;T&gt;(value: T): void</code> with an empty body, so it is an assignability check and nothing more. <code class=\"language-ts\">toExtend</code> is also an assignability check. <code class=\"language-ts\">any</code> passes both. Only the equality matcher rejects it, with a terser error than its usual \"Expected: …, Actual: …\" message:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">src/client.test-d.ts(8,63): error TS2344: Type 'User[]' does not satisfy the constraint 'never'.</code></pre>\n\n<p>When you see <code class=\"language-text\">constraint 'never'</code> from <code class=\"language-ts\">toEqualTypeOf</code>, suspect <code class=\"language-ts\">any</code> on the actual side first. The expect-type README documents the related behaviours: <code class=\"language-ts\">.toEqualTypeOf</code> \"distinguishes between deeply-nested <code class=\"language-ts\">any</code> and <code class=\"language-ts\">unknown</code> properties\", the <code class=\"language-ts\">toBe…</code> matchers \"protect against <code class=\"language-ts\">any</code>\", and <code class=\"language-ts\">.not.toBeAny()</code> guards a return you have just given a real type.</p>\n\n<p>That makes the choice of matcher a decision rather than taste. For inferred results callers depend on, use <code class=\"language-ts\">toEqualTypeOf</code>. Use <code class=\"language-ts\">toExtend</code> 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 <code class=\"language-ts\">toMatchObjectType</code>, which is \"stricter than <code class=\"language-ts\">toExtend</code>\" 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 <code class=\"language-ts\">toMatchTypeOf</code>, it has been deprecated \"since expect-type v1.2.0\"; the README says <code class=\"language-ts\">.toExtend</code> 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.</p>\n\n<h2 id=\"a-type-test-that-mirrors-the-implementation\">A type test that mirrors the implementation</h2>\n\n<p>The expected side of an assertion has to come from somewhere other than the code under test. This one does not:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// Passes whatever ParamNames computes\ntype P = \"/users/:id/orders/:orderId\";\nexpectTypeOf&lt;Params&lt;P&gt;&gt;().toEqualTypeOf&lt;{ [K in ParamNames&lt;P&gt;]: string }&gt;();</code></pre>\n\n<p>Break <code class=\"language-ts\">ParamNames</code> 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 <code class=\"language-ts\">orderId</code> is no longer an accepted key. The mirrored test also had to export <code class=\"language-ts\">ParamNames</code> just to be written, which turns a private helper into something a later refactor must keep.</p>\n\n<p>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.</p>\n\n<h2 id=\"the-runtime-half-in-client-test-ts\">The runtime half in client.test.ts</h2>\n\n<p>Type tests cannot tell you that <code class=\"language-text\">a/b</code> is encoded as <code class=\"language-text\">a%2Fb</code>, or that a 404 becomes <code class=\"language-ts\">{ ok: false, status: 404 }</code>. Those claims go in an ordinary test file that runs in the same <code class=\"language-bash\">vitest run --typecheck</code>:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// src/client.test.ts\nimport { expect, test, vi } from \"vitest\";\nimport { createClient, tryGet } from \"./client.js\";\n\ntest(\"fills and encodes path params\", async () =&gt; {\n  const fetchImpl = vi.fn(async () =&gt; Response.json({ id: \"a/b\", name: \"Ada\", role: \"admin\" }));\n  const client = createClient(\"https://api.example.test\", fetchImpl);\n\n  await client.get(\"/users/:id\", { id: \"a/b\" });\n\n  expect(fetchImpl).toHaveBeenCalledWith(\"https://api.example.test/users/a%2Fb\");\n});\n\ntest(\"a 404 becomes a failed result carrying the status\", async () =&gt; {\n  const fetchImpl = vi.fn(async () =&gt; new Response(null, { status: 404 }));\n  const client = createClient(\"https://api.example.test\", fetchImpl);\n\n  await expect(tryGet(client, \"/users/:id\", { id: \"missing\" })).resolves.toEqual({ ok: false, status: 404 });\n});</code></pre>\n\n<p>Neither file can do the other's job. The narrowing test proves <code class=\"language-ts\">tryGet</code> 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 <code class=\"language-ts\">User</code>. If the response shapes come from captured payloads, the <a href=\"/tools/json-schema\">JSON Schema Generator</a> gives you a starting schema for the runtime check that the type map cannot perform.</p>\n\n<h2 id=\"when-a-type-test-costs-more-than-it-catches\">When a type test costs more than it catches</h2>\n\n<p>Type tests are cheap to run and expensive to maintain, because they fail on changes that are harmless to callers. Skip them when:</p>\n\n<ul>\n<li>The code is application code with no external consumers. The call sites are the type test; <code class=\"language-bash\">tsc</code> already fails when a change breaks one of them.</li>\n<li>The type is an annotation, not an inference. Asserting that a function annotated to return <code class=\"language-ts\">User</code> returns <code class=\"language-ts\">User</code> checks the compiler, not you.</li>\n<li>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\".</li>\n</ul>\n\n<p>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.</p>\n\n<p>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 <code class=\"language-ts\">Params&lt;\"/users\"&gt;</code> should be <code class=\"language-ts\">{}</code> or <code class=\"language-ts\">never</code> 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.</p>\n\n<h2 id=\"questions-about-vitest-type-tests\">Questions about type tests in Vitest</h2>\n\n<h3 id=\"why-not-not-tobecallablewith-for-rejected-calls\">Why not use .not.toBeCallableWith for the refused calls?</h3>\n\n<p>Because it does not handle generic functions well, and this client is one. The expect-type README says <code class=\"language-ts\">toBeCallableWith</code> \"will likely fail if you try to use it with a generic function or an overload\". With the client above, <code class=\"language-ts\">expectTypeOf(client.get).toBeCallableWith(\"/users/:id\", { id: \"x\" })</code> fails: the generic is resolved against the whole <code class=\"language-ts\">Path</code> union, so the checker demands an <code class=\"language-ts\">orderId</code> too. Calling the function directly under <code class=\"language-ts\">@ts-expect-error</code> tests what a user would actually write.</p>\n\n<h3 id=\"does-expecttypeof-do-anything-at-runtime\">Does expectTypeOf do anything if the file does run?</h3>\n\n<p>No. Vitest's API page warns that \"during runtime this function doesn't do anything\", and <code class=\"language-ts\">expectTypeOf</code> 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 <code class=\"language-ts\">expectTypeOf</code> line in a regular <code class=\"language-text\">.test.ts</code> file is only checked if <code class=\"language-bash\">tsc</code> or <code class=\"language-bash\">--typecheck</code> covers that file.</p>\n\n<h2 id=\"pin-one-public-generic-with-type-tests-today\">Pin one public generic with type tests today</h2>\n\n<p>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 <code class=\"language-text\">.test-d.ts</code> file with an equality assertion for each inferred result, a one-line <code class=\"language-ts\">@ts-expect-error</code> for each refused call, and a narrowing test if it returns a union. Add <code class=\"language-bash\">--typecheck</code> to the test script and pin Vitest to an exact version.</p>\n\n<p>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 <code class=\"language-ts\">(path: string)</code> overload and confirm a refusal test reports <code class=\"language-text\">TS2578</code>; run plain <code class=\"language-bash\">vitest run</code> 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.</p>\n\n<h2 id=\"type-level-tests-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://vitest.dev/guide/testing-types\">Vitest — Testing Types</a>. That <code class=\"language-text\">*.test-d.ts</code> files are type tests by default, that Vitest \"calls tsc or vue-tsc\" and \"doesn't run these files\", that <code class=\"language-ts\">test.each</code> names are not evaluated, that overlapping <code class=\"language-text\">include</code> and <code class=\"language-text\">typecheck.include</code> have been reported separately since Vitest 2.1, the <code class=\"language-ts\">answr</code> typo example and the <code class=\"language-text\">test.include</code> remedy, the error-reading guidance, and that the typecheck run uses <code class=\"language-bash\">tsc --noEmit</code> \"so you can remove\" a separate script.</li>\n<li><a href=\"https://vitest.dev/api/expect-typeof\">Vitest — expectTypeOf</a>. That the function does nothing at runtime; the semantics of <code class=\"language-ts\">toEqualTypeOf</code>, <code class=\"language-ts\">toExtend</code>, <code class=\"language-ts\">.resolves</code>, <code class=\"language-ts\">toHaveProperty</code> and <code class=\"language-ts\">toBeCallableWith</code>; <code class=\"language-ts\">toMatchObjectType</code> being \"stricter than <code class=\"language-ts\">toExtend</code>\" and limited to plain object types; and <code class=\"language-ts\">toMatchTypeOf</code> deprecated \"since expect-type v1.2.0\".</li>\n<li><a href=\"https://vitest.dev/config/typecheck\">Vitest — typecheck config</a>. Defaults for <code class=\"language-text\">typecheck.enabled</code> (<code class=\"language-text\">false</code>), <code class=\"language-text\">typecheck.only</code>, <code class=\"language-text\">typecheck.checker</code> (<code class=\"language-text\">tsc</code>, or a binary matching <code class=\"language-bash\">tsc --noEmit --pretty false</code> output), <code class=\"language-text\">typecheck.include</code> and <code class=\"language-text\">typecheck.ignoreSourceErrors</code> (<code class=\"language-text\">false</code>).</li>\n<li><a href=\"https://github.com/vitest-dev/vitest/blob/v5.0.2/packages/vitest/src/typecheck/expectTypeOf.ts\">Vitest v5.0.2 — typecheck/expectTypeOf.ts</a>, with <a href=\"https://github.com/vitest-dev/vitest/blob/v5.0.2/packages/vitest/package.json\">packages/vitest/package.json</a>. That Vitest's <code class=\"language-ts\">expectTypeOf</code> is re-exported from expect-type, and that Vitest 5.0.2 depends on <code class=\"language-text\">expect-type ^1.4.0</code>.</li>\n<li><a href=\"https://github.com/vitest-dev/vitest/blob/v5.0.2/packages/vitest/src/typecheck/assertType.ts\">Vitest v5.0.2 — typecheck/assertType.ts</a>. That <code class=\"language-ts\">assertType</code> is declared as <code class=\"language-ts\">&lt;T&gt;(value: T): void</code> with an empty body, so it checks assignability only.</li>\n<li><a href=\"https://github.com/vitest-dev/vitest/blob/v5.0.2/packages/vitest/src/node/config/resolveConfig.ts\">Vitest v5.0.2 — node/config/resolveConfig.ts</a>. 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.</li>\n<li><a href=\"https://github.com/mmkal/expect-type/blob/v1.4.0/README.md\">expect-type v1.4.0 README</a>. That assertions have no runtime behaviour; that <code class=\"language-ts\">.toEqualTypeOf</code> distinguishes nested <code class=\"language-ts\">any</code> from <code class=\"language-ts\">unknown</code> and the <code class=\"language-ts\">toBe…</code> matchers \"protect against <code class=\"language-ts\">any</code>\"; why <code class=\"language-ts\">.toMatchTypeOf</code> is deprecated and that <code class=\"language-ts\">.toExtend</code> is identical; and the listed limitations for intersections and for <code class=\"language-ts\">toBeCallableWith</code> on generic or overloaded functions.</li>\n<li><a href=\"https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-9.html\">TypeScript 3.9 release notes — // @ts-expect-error Comments</a>. That the directive suppresses the error on the following line and that an error-free line produces <code class=\"language-text\">Unused '@ts-expect-error' directive</code>, plus the guidance to prefer it in test code.</li>\n<li><a href=\"https://github.com/microsoft/TypeScript/releases/tag/v7.0.2\">TypeScript v7.0.2 release</a>. The current TypeScript release at the time of checking, published 2026-08-20, the compiler every example and error message above was produced with.</li>\n</ul>\n"}