Skip to content

test: run the type-level tests via tsc --noEmit instead of tsd - #310

Open
mfal wants to merge 1 commit into
masterfrom
claude/determined-pare-d3f6ef
Open

mfal wants to merge 1 commit into
masterfrom
claude/determined-pare-d3f6ef

Conversation

@mfal

@mfal mfal commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

Problem

packages/commons and packages/models both declared a tsd dependency and carried *.test-types.ts files, but nothing ever executed them:

  • tsd discovers *.test-d.ts by default, and no package had a script invoking it — grep -rn tsd packages/*/package.json only ever hit dependencies.
  • packages/commons had no test:compile target (unlike generator, models and mittwald), so its type tests were not even type-checked as part of the test pipeline.

Affected files:

packages/commons/src/react/ApiCallAsyncResourceFactory.test-types.ts
packages/commons/src/types/RequestFunction.test-types.ts
packages/commons/src/types/RequestType.test-types.ts
packages/commons/src/types/Response.test-types.ts
packages/commons/src/types/assertStatus.test-types.ts
packages/models/src/domain/IngressTarget/IngressTarget.test-types.ts
packages/models/src/react/provideReact.test-types.ts

Decision: drop tsd, add no replacement library

I got tsd running first, to decide on evidence rather than assumption. It works, but only as
tsd --typings dist/types/index.d.ts --files 'src/**/*.test-d.ts', because it ignores
tsd.typingsFile / tsd.testFiles in package.json (those come from CLI flags only — see
node_modules/tsd/dist/lib/index.js). Beyond the plumbing:

  • It pins its own TypeScript (@tsd/typescript 5.4.5 vs. the repo's 5.7.2), so type tests
    would be validated by a different compiler than the one producing the shipped .d.ts.
  • lib/config.js force-sets skipLibCheck: false, overriding the root tsconfig.json — any
    dependency with a sloppy .d.ts breaks the type tests for unrelated reasons.
  • It needs a built dist/types/index.d.ts, so a test:types target would need
    dependsOn: ["build"] plus a new targetDefaults entry.

Against that, test:compile already exists on three packages and nx.json already wires it into
test — so adding that target to commons is sufficient. No assertion library replaces tsd;
everything is plain TypeScript.

How the assertions are now expressed

The tsd helpers do not survive a naive port:

tsd under plain tsc
expectAssignable<T>(e: T) works — typed parameter slot
expectType<T>(e: T) weakened to assignability, not identity
expectNotAssignable<T>(e: any) complete no-op

Assignability → native satisfies

void ({ extra: true } satisfies RequestType) keeps the excess-property checks and literal
inference a real call site passing an object literal gets.

A type-level extends check is not equivalent. Excess properties are invisible to it, so five
assertions would silently stop testing anything — three in RequestType.test-types.ts
(data: { foo: "", extra: "" }, { data: {}, headers: … }, { pathParameters: {}, headers: … })
and two in Response.test-types.ts (the nested extra: "!" and the top-level one). Verified by
running every one of the 22 negative assertions through expectTypeOf(...).not.toExtend<T>():
exactly those five fail to hold. (Weak-type detection does survive a type-level check — it is
part of the assignability relation — so cases like { extra: true } vs. RequestType are caught
either way. Only the excess-property check is lost.)

Handing the literal to a helper function instead reintroduces a different trap — widening:

expectTypeOf({ data: null, status: 400, ...axiosExtras }).not.toExtend<Response400>()
// holds, even though 400 is the CORRECT status: `400` widens to `number`

That is precisely the bug that made the old expectNotAssignable calls vacuous. satisfies cannot
hit it, because the contextual type keeps literals literal.

Exact identity → local ExpectExact<Equals<…>>

type ignoredStatusIsExactlyTheDeclaredOnes = ExpectExact<
  Equals<typeof someResponse.status, 200 | 201 | 400>
>;

typeof respects control-flow narrowing, including nested guards, so this works for the narrowed
assertions in Response.test-types.ts.

The expectNotAssignable calls were vacuous

All four in Response.test-types.ts passed for the wrong reason: with a parameter typed any there
is no contextual type, so status: 200 widened to number and failed the 200 literal regardless
of what the assertion was meant to check. They are now real @ts-expect-error checks on the
specific offending property.

Changes

  • packages/commons/package.json: test → "", added test:compile (tsc --noEmit) and
    test:unit (the former test body) — now matching models / generator.
  • tsd removed from both packages; no dependency added in its place.
  • tsd removed from @mittwald/api-models' runtime dependencies, where it was making every
    consumer install it.
  • New tsconfig.build.json in both, excluding **/*.test.ts and **/*.test-types.ts.
    Necessary: dist was shipping the type tests, and the emitted *.test-types.js imported "tsd",
    a devDependency — a broken import in the published output. test:compile keeps using the plain
    tsconfig.json, so those files are still type-checked.
  • Dropped the stale exclude: ["test-d/**/*", "*.test.ts"] from both tsconfigs — no test-d
    directories exist, and the bare glob only matched package-root files.

Verification

yarn nx run-many -t test:compile,test:unit,lint --parallel=3 --skip-nx-cache
yarn nx run @mittwald/api-client-commons:test --skip-nx-cache

Both pass. commons:test now runs test:compile + test:unit through the dependsOn in
nx.json, and yarn install --immutable is satisfied.

Mutation-tested, since "green" proves nothing for type tests that were previously inert — and
one candidate helper turned out to be silently inert during development (a defaulted type parameter
is not re-checked against its constraint at the use site). Each assertion form was verified to fail
when violated:

mutation result
200 | 201 | 400 → 200 | 201 TS2344: Type 'false' does not satisfy the constraint 'true'
string → string | number (supertype) TS2344 — exactness, not just assignability
string → any TS2344
removed one @ts-expect-error TS2353: 'extra' does not exist in type 'RequestWithOptionalHeaders'

Also confirmed the build-config change drops only test files — built once with each config and
diffed the output file lists: 16 files gone in commons, 6 in models, none added, all exports
map targets still resolve.

Out of scope

  • packages/generator still compiles its unit tests into dist (same defect, untouched here).
  • packages/mittwald's test:client-generation-clean is still just git diff --exit-code, so
    nx run-many -t test fails on a dirty worktree independently of any of this.

🤖 Generated with Claude Code

@mfal mfal self-assigned this Sep 8, 2026
`packages/commons` and `packages/models` both declared a `tsd` dependency and
carried `*.test-types.ts` files, but nothing ever executed them: tsd discovers
`*.test-d.ts`, no package had a script invoking it, and `packages/commons` had
no `test:compile` target at all.

Drop tsd rather than wire it up. It pins its own TypeScript (`@tsd/typescript`
5.4.5 vs. the repo's 5.7.2), force-sets `skipLibCheck: false` over the root
tsconfig, ignores `tsd.typingsFile`/`tsd.testFiles` in package.json so it needs
CLI flags plus a built `dist/types/index.d.ts`, and would need its own nx
target. `test:compile` already exists on three packages and `nx.json` already
wires it into `test`, so adding that target to commons is all it takes.

No assertion library replaces it; everything is expressed in plain TypeScript.
The tsd helpers do not survive a naive port -- under plain tsc,
`expectType<T>(e: T)` weakens from identity to assignability and
`expectNotAssignable<T>(e: any)` is a no-op -- so:

- Assignability is asserted with native `satisfies`, which keeps the
  excess-property checks and literal inference a real call site gets. A
  type-level `extends` check is not equivalent: excess properties are invisible
  to it, which would silently drop five of the assertions here (three in
  `RequestType.test-types.ts`, two in `Response.test-types.ts`). Passing the
  literal to a helper function instead also reintroduces widening -- `status:
  400` becomes `number` -- which is exactly what made the old assertions
  vacuous.
- Exact type identity uses a local `ExpectExact<Equals<...>>` pair. `typeof`
  respects control-flow narrowing, including nested guards, so this works for
  the narrowed assertions in `Response.test-types.ts`.

The four `expectNotAssignable` calls in `Response.test-types.ts` were vacuous:
with a parameter typed `any` there is no contextual type, so `status: 200`
widened to `number` and failed the `200` literal no matter what the assertion
was meant to check. They are now `@ts-expect-error` checks on the specific
offending property.

Also fixes two packaging defects this touches:

- `tsd` sat in `@mittwald/api-models`' runtime `dependencies`, so every
  consumer installed it.
- Both packages compiled their unit tests and type tests into `dist`, and the
  emitted `*.test-types.js` imported `"tsd"` -- a devDependency, i.e. a broken
  import in the published output. A new build-only `tsconfig.build.json`
  excludes them; `test:compile` keeps using the plain `tsconfig.json`, so they
  are still type-checked. Verified that nothing but test files left the build
  output (16 files in commons, 6 in models, none added).

The stale `exclude: ["test-d/**/*", "*.test.ts"]` is gone from both tsconfigs:
no `test-d` directories exist, and the bare glob only matched package-root
files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal force-pushed the claude/determined-pare-d3f6ef branch from 92f00ef to aed4607 Compare September 24, 2026 08:00
@mfal
mfal marked this pull request as ready for review September 24, 2026 08:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant