Skip to content

fix: scaffolded apps get real core types and a usable typescript range - #1452

Merged
vivek7405 merged 5 commits into
mainfrom
fix/core-dts-any-exports
Aug 21, 2026
Merged

fix: scaffolded apps get real core types and a usable typescript range#1452
vivek7405 merged 5 commits into
mainfrom
fix/core-dts-any-exports

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #1451

Two fixes to the TypeScript a scaffolded app actually receives. They were found together, and the second one is what the first one's investigation turned up on the way.

1. The core and server exports an app resolves as any

html was typed any in every scaffolded app, and so were css, TemplateResult, Suspense, repeat, connectWS, richFetch and escapeText / escapeAttr. packages/core/index.d.ts re-exported those seven modules from their JSDoc .js implementation with no .d.ts sibling, and an app has allowJs off, so each one degraded to any (TS7016), silenced by the scaffold's skipLibCheck: true. It spread past the direct imports, because src/component.d.ts, src/routes.d.ts and src/directives.d.ts reach for the same untyped modules, so a component's render() return, its static styles, a page's return type and repeat from @webjsdev/core/directives were all unchecked.

This adds the seven missing overlays. In a real generated app, render() goes from any to TemplateResult and html from nothing to (strings: TemplateStringsArray | string[], ...values: unknown[]) => TemplateResult.

@webjsdev/server carried the same class of break in one place. RequestHandler.handle was typed Handle on the strength of the export * from './src/testing' above it, but export * re-exports a name without creating a local binding, so it was TS2304: Cannot find name 'Handle' and handle was an error type. It is imported explicitly now. Fixing it exposed a genuinely too-narrow signature in the gallery's rate-limit test, which had restated the handler type as (req: Request) => Promise<Response> where Handle is ... => Promise<Response> | Response; it derives the type now, per the derive-the-type rule.

Also drops the explicit .d.ts extension from the two value export * specifiers (TS2846).

Why the existing guards missed it

test/types/dts-export-coverage.test.mjs (#388) and test/types/dts-no-phantom-exports.test.mjs (#1031) check export EXISTENCE in both directions, and both run tsc with --allowJs. That flag reads the JSDoc out of the .js, which is exactly what an app never gets, so a name that resolves to any in every app resolved to a real type in both guards. They also assert a name is declared, never that it carries a type.

test/types/dts-no-any-exports.test.mjs inverts both flags (allowJs off, skipLibCheck off) and grades the overlays the way an app resolves them.

2. The scaffold declared a TypeScript it cannot use

The generated package.json declared "typescript": "^5.6.0" while the tsconfig.json the same generator writes sets erasableSyntaxOnly, which landed in 5.8. Every version in the lower half of that range refuses the config outright:

tsconfig.json(18,5): error TS5023: Unknown compiler option 'erasableSyntaxOnly'.

exit 2, nothing else checked. Confirmed on 5.6.3 and 5.7.3, both inside the declared range. It stayed hidden because npm resolves a caret to the newest match, so a fresh scaffold picked up 5.9 and worked; it bites a pinned install, an older lockfile, or an editor whose own compiler is older.

The range moves to ^6.0.3, the major gallery, website and examples/blog already use, so an app and the framework that generated it type-check under one compiler. test/scaffolds/scaffold-typescript-floor.test.js ties the two files together: it maps every compiler option the generator emits to the release that introduced it and asserts the range's lowest satisfying version clears the highest of those floors. An option missing from the table fails the test rather than being skipped, so adding one has to record its floor.

Test plan

  • node --test test/types/dts-no-any-exports.test.mjs (4/4)
  • node --test 'test/scaffolds/*.test.js' (68/68)
  • Counterfactuals, all three proven by toggling the fix and re-running: deleting one of the seven new .d.ts reds the no-any guard; restoring ^5.6.0 reds the floor guard; adding an unclassified compiler option reds it too
  • Full Node suite: the only failures are the five this checkout always shows in a linked worktree (test/bun/listener, test/bun/listener-overhead, and three differential-elision assertions), all runtime tests that pass in the primary checkout and in CI
  • Both templates generated from this branch: webjs check clean, and tsc --noEmit clean under TypeScript 6.0.3, the new floor. html and TemplateResult resolve to real types in the generated app
  • website: tsc --noEmit and webjs check both clean after the docs edit
  • Browser, e2e, Bun matrix, two-app dogfood boot: N/A for fix 1, whose diff carries no runtime source at all. Fix 2 touches the generator, covered by the scaffold suite plus the generate-and-typecheck above.

Doc surfaces

  • website/app/docs/backend-only/page.ts: its sample api-template manifest showed "typescript": "^5.7.0", itself below the floor, so it demonstrated a config that cannot be read. Corrected to match the generator.
  • Framework docs / skill / README: N/A. No public API moves, and no doc states a TypeScript range for a generated app beyond the sample above.
  • Scaffold: the generator emits the new range, and gallery/test/rate-limit/rate-limit.test.ts derives its helper param from Handle.

Note on scope

#1451 tracks fix 1 only. Fix 2 has no issue of its own; it was found during the same investigation and folded in here at the owner's request rather than filed separately.

@vivek7405 vivek7405 self-assigned this Aug 21, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design note: why the guard detects the cause instead of the any

My first cut of dts-no-any-exports asked the type system directly: map every value export whose type is any to a greppable marker, using the usual type IsAny<T> = 0 extends (1 & T) ? true : false. It reported nothing, on a package I already knew was broken.

The reason is worth writing down, because the obvious detector is the wrong one here. An export that resolves through a missing .d.ts is not any, it is TypeScript's ERROR type, and the error type absorbs conditionals rather than satisfying them. I probed four detectors against it: 0 extends (1 & T), unknown extends T, and a bare T extends X all evaluate to the error type, so ... extends true filters them out and the mapped type comes back empty. Only [T] extends [never] resolved at all, and it answers the wrong question. So a type-level sweep is structurally incapable of seeing this defect, and would have shipped as a test that passes forever.

What does discriminate is the cause: run tsc the way an app resolves the package, with allowJs off and skipLibCheck off, and the missing sibling surfaces as TS7016 located in the overlay itself. That is how I found the bug by hand, so that is what the guard does.

The headline test keeps a type-level check but probes by ASSIGNMENT, to a branded type nothing real inhabits. A real type errors; any and the error type both assign silently. That one works because it never asks the checker to reason about the bad type, only to accept or reject an assignment. It also reads any diagnostic on the line rather than a specific code, since a function mismatch is TS2322 and an object one is TS2741, and keying on TS2322 alone made TemplateResult look like a failure.

Why the guard pins paths to this checkout

packages/server/index.d.ts imports @webjsdev/core bare. In a git worktree that resolves through the shared node_modules symlink into the PRIMARY checkout, so the first working version of this guard graded the wrong copy: it reported the unfixed primary's thirteen errors while the branch under test was already clean. It generates a tsconfig with absolute paths for @webjsdev/* now, so it always grades the checkout it is running in. Absolute values with no baseUrl, because baseUrl is deprecated from TypeScript 6 and the repo is on ^6.0.3, and an explicit typeRoots, because the generated tsconfig lives in a temp dir and types resolves relative to it.

The scaffold change was not planned, and is the interesting part

Fixing Handle immediately red the gallery's rate-limit test with seven errors. That test had restated the handler type as (req: Request) => Promise<Response>, which is narrower than Handle (... => Promise<Response> | Response). It only ever passed because RequestHandler.handle was an error type and swallowed the mismatch. So the type fix did not break the test; it revealed that the test had been unchecked, which is a small illustration of what the whole any surface was costing.

@vivek7405 vivek7405 changed the title fix: type the core and server exports an app resolves as any fix: scaffolded apps get real core types and a usable typescript range Aug 21, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decision: why the range went to ^6.0.3 rather than the minimum ^5.8.0

The bug only requires clearing 5.8, so ^5.8.0 would fix it. I went to ^6.0.3 because a floor that merely clears the bug re-creates the same shape of problem later: it leaves the scaffold on a major nobody in this repo is testing, and the next option with a newer floor lands the same way. gallery, website and examples/blog are all on ^6.0.3, so this puts an app on the compiler the framework that generated it is checked with, which is the property I actually want.

I checked the range is not just newer but correct, by generating both templates and type-checking them under a real 6.0.3: clean on both. I had also checked 7.0.2 earlier while chasing the original report, also clean, so the eventual move to 7 is a range bump rather than a migration.

Why the guard reads a table instead of just asserting a constant

The simplest guard would be assert(range >= '5.8.0'), and it would be wrong in a year. The failure here was not that a number was too low, it was that two generated files were free to drift because nothing connected them. So the test derives the requirement from the tsconfig the generator actually emits, through a table of option-to-introducing-release, and takes the max. An option missing from that table fails the test rather than being skipped, which is what makes it hold: adding a compiler option to the scaffold forces you to record what it costs, the same "classify it or CI stays red" contract the gallery-coverage manifest uses.

Both halves are proven by toggling: restoring ^5.6.0 reds it, and adding an unclassified verbatimModuleSyntax reds it too.

The docs sample was independently wrong

website/app/docs/backend-only/page.ts showed "typescript": "^5.7.0" in its api-template manifest. That never matched the generator (which said 5.6), and 5.7 is itself below the floor, so the documented config was one TypeScript refuses to read. It matches the generator now.

I did not add a webjs doctor probe for this

It was tempting, since doctor already knows about erasableSyntaxOnly. I left it alone: doctor inspects an app that already exists, and by then the user has whatever TypeScript they installed, so the honest fix is upstream in what the scaffold writes. A probe would report a problem the generator should never have created.

`@webjsdev/core`'s overlay re-exported seven modules from their JSDoc `.js`
with no `.d.ts` sibling. An app has `allowJs` off, so `html`, `css`,
`TemplateResult`, `Suspense`, `repeat`, `connectWS`, `richFetch` and the
escape helpers all resolved to `any` there, silenced by `skipLibCheck`. That
took a component's `render()` return, its `static styles` and a page's return
type with them, so a scaffolded app type-checked almost none of its templates.

`@webjsdev/server` had the same class of break in one spot: `RequestHandler`
referenced `Handle` on the strength of an `export *`, which re-exports a name
without binding it locally, so `handle` was an error type. Fixing it exposed a
real too-narrow signature in the gallery's rate-limit test, which now derives
the type from `Handle` instead of restating it.

The two existing drift guards could not see any of this: both run tsc with
`--allowJs`, which reads the JSDoc the app never gets. The new guard inverts
that flag and `skipLibCheck` so it grades the packages the way an app does.
The generated package.json declared `"typescript": "^5.6.0"` while the
tsconfig.json the same generator writes sets `erasableSyntaxOnly`, which
landed in 5.8. Every version in the lower half of that range refuses the
config outright with `TS5023: Unknown compiler option`, exit 2, nothing
else checked. It stayed hidden because npm resolves a caret to the newest
match, so a fresh scaffold picked up 5.9; it bites a pinned install, an
older lockfile, or an editor whose own compiler is older.

The range moves to the major the repo's own three apps already use, so an
app and the framework that generated it type-check under one compiler.
Both templates were generated and type-checked clean under 6.0.3.

Nothing tied the two files together, so a new guard does: it maps every
compiler option the generator emits to the release that introduced it and
asserts the range's LOWEST version clears the highest of those floors. An
option missing from the table fails the test rather than being skipped, so
adding one has to record its floor.

The docs site showed `^5.7.0` in its api-template manifest, itself below
the floor, so that sample is corrected too.
The generated tsconfig is plain JSON.stringify output with no comments;
the comment-stripping is defensive, not a present need. Say so.
@vivek7405
vivek7405 force-pushed the fix/core-dts-any-exports branch from a3cc71f to 20335c2 Compare August 21, 2026 13:33

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went over this end to end: each new overlay against the runtime module it declares, both index files, the two guards, and the generator and docs constants.

The overlays are honest. Export sets match their .js exactly in both directions, including SUSPENSE, which is easy to miss because index.d.ts does not re-export it. Two places refine on the JSDoc rather than copy it, isRepeat / isSuspense as type predicates and connectWS's onMessage payload as unknown, which is the divergence the phantom guard's own header calls out as deliberate house style, so I am happy with both. MARKER keeps its literal type and its value is untouched, which matters given #730.

The part I like most is that the new guard detects the CAUSE rather than the symptom. A type-level any sweep would have been the obvious build and it would have been dead on arrival, because an unresolved import is the error type and that absorbs conditionals. Inverting allowJs and skipLibCheck and reading TS7016 is the thing that actually discriminates, and pinning paths at this checkout is what stops it grading the primary through the worktree symlink. Both are the sort of decision that looks arbitrary in six months, so I am glad the reasoning is written on the PR.

One real problem, on the floor guard's comment, flagged inline and already fixed. One thing I looked at and decided to leave, also inline.

Main moved under this branch while I was reading, so I rebased onto #1450 and re-ran both guards on the new head.

Comment thread test/scaffolds/scaffold-typescript-floor.test.js
Comment thread test/types/dts-no-any-exports.test.mjs
Typing RequestHandler.handle for real broke every place that had
restated it as (req: Request) => Promise<Response>. Those only passed
while Handle was an unbound name, so the error type absorbed the
mismatch: the website's four SSR test wrappers, and the server export
fixture. The wrappers become async, the fixture states the real union.

connectWS's onMessage payload goes back to `any`, matching its JSDoc.
Refining it to `unknown` broke the blog's chat and comments handlers,
which name the message shape they expect. That is the contract, and a
PR filling in missing declarations does not get to change it.

The new no-any guard joins the bun denylist beside its #1031 sibling:
it spawns process.execPath as Node tsc, so under the matrix it spawns
bun and every probe reads as `any`.

Local runs missed all of this because a linked worktree resolves bare
@webjsdev/* to the PRIMARY checkout, so the tests graded an unfixed
copy. Verified by shadowing the packages into each test tree, which
reproduced CI exactly.
The no-explicit-any suppression I put on connectWS's onMessage suppresses
nothing here: this repo has no eslint config and no lint script. The
comment above it already carries the reason the `any` is deliberate.
@vivek7405
vivek7405 marked this pull request as ready for review August 21, 2026 14:08
@vivek7405
vivek7405 merged commit b8e8bd3 into main Aug 21, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/core-dts-any-exports branch August 21, 2026 14:08
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.

dogfood: html, css and TemplateResult resolve to any in every scaffolded app

1 participant