Imported from tomada1114/instant-composition (
.claude/skills/type-testing/SKILL.md). Install upstream withnpx skills add tomada1114/instant-composition --skill type-testing. Copyright stays with the author.
Type Testing
Owns: type-level assertions — what to assert about a type, where the assertion goes,
and the ways such an assertion silently passes without testing anything. Does not
own: runtime behavior assertions (writing-tests); which file a test lives in and
which vitest project it joins (placing-tests); the type-system judgment inside the
module being asserted about (writing-typescript).
Where a type assertion goes
There is no separate types-only test file. An expectTypeOf assertion lives in the same
suite as the runtime tests for the surface it checks, so a change to that surface breaks
both halves in one place instead of leaving a type file nobody opened. Today that means
tests/domain-result.test.ts for the Result vocabulary and tests/messages.test.ts
for the catalog's key union.
An it() whose whole assertion is type-level is legitimate and needs no runtime
expect. Enforced by: eslint.config.mjs's vitest/expect-expect, which lists
expectTypeOf among its assertFunctionNames precisely so such a case is not reported
as assertion-less.
What is worth asserting
Assert what inference is supposed to preserve, not what the annotation already says:
- A generic that must not widen. A function returning
Result<z.infer<TSchema>, E>must hand the caller's own schema type back rather than a widened one. The assertion is on a concrete call —expectTypeOf(value).toEqualTypeOf<{ answer: string; confidence: number }>()— because only a concrete input can prove the generic was not collapsed to its constraint. - A discriminated union narrowing on its discriminant.
Resultnarrows onok; assert both branches (see Trap 2 for how to do it without proving nothing). - Which inputs are accepted and which are rejected. A request object missing a
required field, or carrying a field the interface does not declare, must fail to
compile — with
@ts-expect-error, written the way Trap 1 describes. - A literal list that has to stay in step with a union. Close the list with
as const satisfies readonly TheUnion[]and then compare it to the union withexpectTypeOf<(typeof LIST)[number]>().toEqualTypeOf<TheUnion>(). The two halves pull in opposite directions:satisfiesrejects an entry that is not a member, and theexpectTypeOfrejects a member the list forgot. Annotating the listreadonly TheUnion[]instead would lose the literal tuple type and let a new member land with no case for it.MESSAGE_KEYSintests/messages.test.ts, checked againstMessageKey(DottedKeys<Messages>, derived in that same file from theMessagestype@instant-composition/webexports), is the model: the manifest and the derived union both live in the test because nothing in the web client reads either — a compile-time assertion belongs wherever the two things it holds together live, in source when one of them is a constant the application ships, in a test when what is being pinned is an inference the source cannot state about itself.
Trap 1: a @ts-expect-error inside it() still runs
Vitest executes the body of it(). A directly inlined invalid call is therefore
evaluated at test time, not just type-checked — if the call happens to throw or have a
side effect, the test can pass or fail for the wrong reason, and if the assertion
depends on a code path never reached, nothing was proven at all.
// Wrong: runs the invalid call as part of the test body.
it("rejects an input with no id", async () => {
// @ts-expect-error id is required by Input
await build({ name: "example" });
});
// Right: declare the invalid call inside a function, never invoke it. The
// assertion is that the function fails to compile.
it("rejects an input with no id", () => {
const rejected = (): unknown =>
// @ts-expect-error id is required by Input
build({ name: "example" });
expect(rejected).toBeTypeOf("function");
});
Trap 2: a union initializer narrows before the assertion runs
TypeScript narrows a const on its initializer, so
const result: Result<T, E> = { ok: false, error } infers the failure member rather
than the union. An assertion against a variable declared that way tests one concrete
member and proves nothing about the branch that is supposed to widen and narrow.
// Wrong: `result` is already the failure member, so the union is never tested.
const result: Result<Answer, RangeError> = {
ok: false,
error: new RangeError("out of range"),
};
// Right: receive the value as a function parameter, so the annotation on the
// parameter — not the argument's own type — is what narrowing is checked against.
function classify(result: Result<Answer, RangeError>): void {
if (result.ok) {
expectTypeOf(result.value).toEqualTypeOf<Answer>();
} else {
expectTypeOf(result.error).toEqualTypeOf<RangeError>();
}
}
classify(parseAnswer(input));
The narrowing only bites when the initializer's own type is a single member of the
declared union — an object literal, or a class instance.
const result: Result<string, RangeError> = ok("hello") keeps the union, because ok
returns Result<T, never>, itself a union; that is why the narrowing cases in
tests/domain-result.test.ts are real tests rather than instances of this trap. Do not
rely on the difference: a parameter is unambiguous, and an initializer's type can change
under you.
Trap 3: @ts-expect-error can be satisfied by the wrong error
@ts-expect-error only asserts that the next line fails to compile — it does not check
why. A typo'd property name and a genuine type-contract violation both satisfy it
equally, so a rename or an unrelated refactor can leave the comment green while testing
nothing you intended. Wherever the expected failure is about a type (a wrong argument
type, a wrong return type) rather than a missing symbol, pair the @ts-expect-error
with an expectTypeOf assertion on the correct call next to it, so the test still fails
if the error moves to a different line or a different cause.
eslint.config.mjs requires a description of at least ten characters on every
@ts-expect-error and bans @ts-ignore outright — the description is what a reader
compares the actual failure against.
Annotated generic exports
A generic function whose return type is hand-annotated instead of left to inference can
be annotated wider than what the implementation actually returns, which silently loses
precision for every caller. Any exported generic with an explicit return annotation
needs a type test proving the annotation is no wider than the inferred type — compare
expectTypeOf(fn(...)).toEqualTypeOf<...>() against a call whose input is concrete
enough to pin down the narrowest expected result. ok<T>(value: T): Result<T, never> is
the worked example in the tree. BACKGROUND: writing-typescript explains why
annotating a generic export is the standard way to accidentally widen it.
What these assertions do not cover
They check the type-checker's view of a module as compiled from a test — nothing here is packed or published, so there is no separate consumer-side resolution to diverge from it. Two consequences worth planning around:
- A type assertion is erased before anything runs. It says nothing about whether the value at runtime matches the type, which is why a schema-validated value is asserted both ways: once for the inferred type, once for the value.
- Some type contracts are declared by module augmentation in the web client rather than
asserted in a test:
apps/web/src/router.tsxregisters the route tree with TanStack Router, so aLinkto a path it does not hold fails to compile, andapps/web/src/i18n/messages.tsregisters the catalog withuse-intl, so auseTranslationskey outside it does.pnpm typecheckis what sees those, throughapps/web/tsconfig.json; anexpectTypeOfintests/would only restate them.