Skip to content

feat: support TypeScript 6.0 - #1878

Open
gabsong wants to merge 5 commits into
lukeautry:masterfrom
GoAugment:feat/typescript-6-support
Open

feat: support TypeScript 6.0#1878
gabsong wants to merge 5 commits into
lukeautry:masterfrom
GoAugment:feat/typescript-6-support

Conversation

@gabsong

@gabsong gabsong commented Sep 1, 2026

Copy link
Copy Markdown

closes #1877

All Submissions:

  • Have you followed the guidelines in our Contributing document?
  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?
  • Have you written unit tests?
  • Have you written unit tests that cover the negative cases (i.e.: if bad data is submitted, does the library respond properly)?
  • This PR is associated with an existing issue?

No new test files are added. Please see the test plan below — the three fixes are only observable when the compiler is TypeScript 6, and the existing suite already covers them once the new CI matrix leg runs it there. Happy to add dedicated tests if you would prefer them anyway.

What this does

TypeScript 6.0 is the last release of the JavaScript-based compiler, and npm's latest is now the native TypeScript 7, which does not expose the JS compiler API @tsoa/cli needs. Users upgrading to TypeScript 6 currently have to drop tsoa. This makes tsoa build and generate correctly on TypeScript 6 while keeping TypeScript 5 fully supported.

Five commits, smallest first:

  1. fix(cli): mask symbol flags with SymbolFlags when resolving keyof — the generic keyof T branch masked a symbol's flags with ts.TypeFlags.TypeParameter. It only ever matched because TypeFlags.TypeParameter and SymbolFlags.TypeParameter were both 262144. TS 6 renumbers the former to 524288, so generation fails with Could not determine the keys on T. This is a latent bug that happens to be invisible before TS 6.
  2. fix(cli): keep lib declarations for types declared only by TypeScriptgetModelTypeDeclarations drops declarations under node_modules/typescript so a user model shadowing a lib name wins. TS 6 ships more lib declarations, so Error has several lib declarations and no user declaration, the filter removes all of them, and interface CustomError extends Error fails with Could not find declarations for type 'Error'. Now the filter is only applied when it leaves something behind.
  3. fix(cli): keep non-strict compiler defaults when none are configuredMetadataGenerator builds the program with compilerOptions || {}. Through TS 5 that meant strictNullChecks: false; TS 6 enables it by default, so optional properties become T | undefined, conditional types take the other branch, and specs silently change shape (optional boolean emitted as "type": "object"). Defaults explicitly to { strictNullChecks: false } so projects that configure nothing keep today's output. Projects that pass compilerOptions are untouched.
  4. chore(lint): move to typescript-eslint v8 — 6.16.0 only supports TS <5.4; on TS 6 its type resolution collapses into ~1100 spurious errors, so the repo cannot be linted there at all. v8 supports >=4.8.4 <6.1.0, covering both matrix legs. Includes the preset rename (recommended-requiring-type-checkingrecommended-type-checked, removed in v7) and the source/test adjustments its newly-enabled rules require.
  5. feat: support TypeScript 6.0 — widens typescript to ^5.7.2 || ^6.0.0 in every package, adds ^6.0.0 to the CI matrix, and migrates the repo's own tsconfigs and fixtures off options TS 6 turned into errors (downlevelIteration, baseUrl, moduleResolution: "node", the esModuleInterop default flip, TS1540 export module, and one side-effect import of a controller that does not exist).

TypeScript 7 is explicitly out of scope.

Potential Problems With The Approach

  • The strictNullChecks default is the main judgement call. I chose to preserve current output for projects that configure no compilerOptions, rather than letting TS 6's defaults through and updating the expected specs — the latter would make generated output differ between the TS 5 and TS 6 CI legs. The trade-off is that tsoa now states a default instead of inheriting the compiler's. Worth a maintainer opinion.
  • Interaction with read project tsconfig.json when resolving compiler options #1865. That PR makes validateCompilerOptions read the project's tsconfig.json. Once it lands, the CLI path will usually pass real options, so this default mostly applies to programmatic MetadataGenerator callers — but a consumer whose tsconfig sets strict: true would then get T | undefined optional properties on TS 6. Worth deciding together which of the two behaviours you want; happy to rebase on whichever merges first.
  • resolutions now forces TypeScript 6 onto lerna, which declares typescript: ">=3 < 6". Everything builds, lints and tests fine (lerna only orchestrates scripts here), but it is a range violation created by the root resolutions pin, so flagging it.
  • tests/esm moves to moduleResolution: "bundler" rather than node16/nodenext. bundler matches how its mocha loader already resolves specifiers (--experimental-specifier-resolution=node, extensionless imports); nodenext would require adding extensions throughout. Say the word if you would rather have the stricter setting.
  • The fixture changes are mechanical but touch many files, which makes the diff look larger than the behavioural change actually is.

Test plan

The mechanism is commit 5 adding ^6.0.0 to the CI typescript-version matrix: it runs the entire existing suite against TypeScript 6, where it did not run before. Each of the three fixes has existing tests that fail without it on that leg:

  • keyof fix — without it, tests/prepare.ts aborts before any test runs: KeysMember<T> in tests/fixtures/testModel.ts (keysOfAny, keysOfInterface) throws Could not determine the keys on T. The whole suite is blocked.
  • lib-declarations fix — without it, tests/prepare.ts aborts the same way on CustomError extends Error in tests/fixtures/controllers/getController.ts: Could not find declarations for type 'Error'. Again the whole suite is blocked.
  • strictNullChecks fix — with route generation unblocked, 24 definition-generation assertions fail without it, across defaults, mappeds, conditionals, advancedTypeAliases, nestedTypes, jsDocTypeNames and jsdocMap in tests/unit/swagger/definitionsGeneration/definitions.spec.ts — e.g. defaults.basic.boolValue3 expected "type": "boolean", actual "type": "object".

I verified both matrix legs locally, building each package and running lint plus both test suites end to end:

TypeScript 5.9.3 TypeScript 6.0.3
build (all packages) pass pass
lint pass pass
tests 1946 passing 1946 passing
tests/esm 2 passing 2 passing

Since the fixes are version-conditional, a dedicated unit test would assert the same thing the existing suite already asserts, and would pass on the TS 5 leg either way. I went with the matrix leg as the regression guard, but I am glad to add explicit tests if you would rather have them pinned down independently.

The generic `keyof T` branch of TypeResolver masked a *symbol*'s flags with
`ts.TypeFlags.TypeParameter`. That only ever matched because TypeFlags and
SymbolFlags happened to share the value 262144 for TypeParameter.

TypeScript 6.0 renumbers `TypeFlags.TypeParameter` to 524288, so the check
never matches and generating metadata for a model containing `keyof T` fails
with "Could not determine the keys on T".

Mask with `ts.SymbolFlags.TypeParameter`, which is correct on every supported
TypeScript version.
When a type name has several declarations, getModelTypeDeclarations drops the
ones coming from TypeScript's own lib files so that a user model shadowing a
lib name (e.g. 'Account') wins.

TypeScript 6.0 ships more lib declarations, so built-in types such as `Error`
now have multiple lib declarations and no user declaration. The filter removed
all of them and metadata generation failed with "Could not find declarations
for type 'Error'" for models like `interface CustomError extends Error`.

Only apply the filter when it leaves at least one declaration behind.
MetadataGenerator compiles the user's project with `compilerOptions || {}`.
Up to TypeScript 5.x an empty option bag meant `strictNullChecks: false`, and
the generated specs were built on that assumption.

TypeScript 6.0 enables strict null checking by default. With an empty option
bag optional properties resolve as `T | undefined` and conditional types take
the other branch, so specs silently change shape: `boolean` properties are
emitted as `object`, mapped and conditional aliases resolve differently.

Default explicitly to `{ strictNullChecks: false }` so a project that does not
configure compilerOptions gets the same spec on TypeScript 5 and 6. Projects
that do pass compilerOptions keep full control.
@typescript-eslint 6.16.0 only supports TypeScript <5.4. Under TypeScript 6 its
type resolution collapses and it reports roughly 1100 spurious "unsafe any"
errors, so the repo cannot be linted on TS 6 at all. v8 supports >=4.8.4 <6.1.0
and covers both TypeScript versions in the CI matrix.

The `recommended-requiring-type-checking` preset was renamed to
`recommended-type-checked` in v6 and the old name was removed in v7.

The v8 presets enable rules the repo did not run before:

- source: drop assertions the compiler no longer needs, use a non-null
  assertion where typeToTypeNode's optional result is narrowed, turn a
  short-circuit warn into a statement, and annotate the two @hapi/boom calls
  that are injected into the template service as CallableFunction.
- tests: disable the rules that fight chai's expression-style assertions
  (`expect(x).to.be.ok`) and mocha's promise-returning callbacks.
Widen the typescript dependency to `^5.7.2 || ^6.0.0` in every package so
consumers can build tsoa projects with TypeScript 6, and add `^6.0.0` to the
CI matrix next to `^5.0.0` so both majors are built, linted and tested.

TypeScript 6.0 turns several deprecations into errors, so the repo's own
configuration and fixtures needed updating:

- drop `downlevelIteration`, a no-op at the es2021 target, and `baseUrl`.
  The imports that relied on `baseUrl` to resolve (`fixtures/*`, `unit/*`)
  keep working through `paths`, which no longer requires `baseUrl` and which
  tests/tsconfig.json already used for `@tsoa/cli/*` and `@tsoa/runtime/*`.
- `tests/esm` moves from the deprecated `moduleResolution: "node"` to
  `"bundler"`, which matches how its mocha loader already resolves specifiers.
- set `esModuleInterop` explicitly in tests/tsconfig.json. TS 6 flips its
  default to true, so the fixtures that imported callable CommonJS modules
  (express, koa, supertest, ...) as namespaces move to default imports.
- `export module Namespace2` becomes `export namespace Namespace2` (TS1540)
  and a side-effect import of a controller that does not exist is removed
  (TS2882).

TypeScript 7 is out of scope: it is the native compiler and does not expose
the JS compiler API that @tsoa/cli relies on.
@gabsong
gabsong force-pushed the feat/typescript-6-support branch from 01a9c8f to adc305b Compare September 1, 2026 06:39
@gabsong

gabsong commented Sep 1, 2026

Copy link
Copy Markdown
Author

CI note: one pre-existing test failure, not introduced by this PR

I ran this branch through CI on my fork (the workflow triggers on: push, so it runs there without needing the action_required approval upstream). One test fails:

Koa Server (with multerOpts)
  file upload
    cannot post file more than default 8mb:
  Error: Should raise error about file too large and status 500.

1945 passing, 1 failing

It is not caused by this PR. I pushed unmodified master (f0f9aa79, no changes at all) to the same fork as a control, and it fails the identical test:

Ref Job Result
this branch Build (25, ubuntu-latest, ^6.0.0) same failure (reproduced on re-run)
this branch Build (24, macos-latest, ^6.0.0) same failure
unmodified master f0f9aa79 Build (25, macos-latest, ^5.0.0) same failure, 1945 passing / 1 failing

So it reproduces on master, on TypeScript 5, and across both Node 24/25 and macOS/ubuntu — the OS and Node version that report it just depend on which sibling fail-fast cancels first.

Locally the full suite passes (1946 passing) on both TypeScript 5.9.3 and 6.0.3, which points at the environment rather than the code. My best guess at the mechanism, for whatever it is worth:

  • The workflow installs with yarn install --ignore-scripts --no-lockfile, so CI resolves transitive dependencies fresh instead of from yarn.lock. A different resolved busboy/@koa/multer than the locked one (locally busboy@1.6.0, @koa/multer@3.0.2) would explain a local/CI split.
  • The assertion is on an exact boundary: the test writes exactly 8 * 1024 * 1024 = 8388608 bytes and expects rejection against tsoa's default limits.fileSize of exactly 8388608, while the sibling test writes 8388607 and expects success. A limit check that trips on "exceeds" rather than "reaches" makes the passing/failing side of that boundary sensitive to the resolved busboy version.
  • The test also still uses the removed-in-spirit new Buffer(...) (DEP0005 is logged immediately before the failure).

I have deliberately not touched that test in this PR, since it is out of scope and fixing it would muddy the TypeScript 6 diff. Happy to open a separate issue for it, or to include a fix here if you would prefer that.

Aside from this one pre-existing failure, both matrix legs are green — build, lint and the full suite pass on TypeScript 5.9.3 and 6.0.3.

@gabsong

gabsong commented Sep 2, 2026

Copy link
Copy Markdown
Author

Correction to my previous comment: I guessed at busboy drift, and that was wrong. busboy resolves to 1.6.0 either way. The behaviour change is in multer, and I can now show it exactly.

yarn.lock pins multer@2.0.2; CI installs with --no-lockfile, so it resolves the current multer@2.3.0. Running the same upload against both, with limits.fileSize = 8388608 (tsoa's default):

file size multer 2.0.2 (lockfile) multer 2.3.0 (what CI installs)
8388607 (limit − 1) 200 accepted 200 accepted
8388608 (exactly the limit) 500 File too large 200 accepted
8388609 (limit + 1) 500 File too large 500 File too large

So multer moved the cut-off from "reject when the file reaches the limit" to "reject when it exceeds the limit". Anything genuinely over the limit is still rejected in both versions, so the upload cap itself is intact — the only case that flips is a file of exactly the limit, which is precisely what cannot post file more than default 8mb asserts:

writeFileSync('./moreThan8mb', new Buffer(8 * 1024 * 1024)); // === limits.fileSize

That makes the test dependent on multer's old off-by-one rather than on tsoa behaviour, and --no-lockfile is what exposes it. The one-line fix is to make the file genuinely larger than the limit, which passes on both versions:

writeFileSync('./moreThan8mb', Buffer.alloc(8 * 1024 * 1024 + 1));

(Buffer.alloc also drops the DEP0005 Buffer() is deprecated warning the test currently emits.)

I have left this out of this PR to keep the TypeScript 6 diff focused, and it is unrelated to these changes — it reproduces on unmodified master as shown above. Happy to send it as a separate PR, or to fold it in here if you would rather this branch go green.

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.

Support TypeScript 6.0 so tsoa projects can upgrade

1 participant