Skip to content

Follow a project's tsconfig for ambient declarations, types and class field order - #3207

Closed
cramforce wants to merge 7 commits into
feat/ts-import-elisionfrom
feat/ts-project-options
Closed

cramforce wants to merge 7 commits into
feat/ts-import-elisionfrom
feat/ts-project-options

Conversation

@cramforce

@cramforce cramforce commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #3171 (import elision); the first commit here is that PR's.

What

scriptc checks every project with its own compiler settings and takes only the strictness flags, paths and the JSX settings from tsconfig.json. Of 19 open-source TypeScript projects tried against main, 9 stopped in preflight on that difference, with TypeScript errors that their own tsc does not report. This PR takes from the project what does not change what the native program means, follows one more setting in the lowering, and names the settings that keep one meaning.

Taken from the project:

  • Its own declarations. The .d.ts files that files and include name (outside node_modules) and the packages that types names (through typeRoots when set) join the program. The program used to grow from the entry's imports only, so a project's global declarations were never read. An API declared there and not implemented is refused where it is used, by name, instead of failing the type check.
  • The order of class field initializers (target below ES2022, or useDefineForClassFields: false, read from the nearest tsconfig above each file). tsc then initializes fields in the constructor, after its parameter properties, so private size = this.items.length may read the parameter property items. ES2022 class fields run the initializer first and read undefined, which TypeScript reports as TS2729. The lowering now emits the parameter-property assignments first for classes of such files, and that TS2729 is not reported there; the same error about a field declared further down stays. A tsconfig that does not mention target keeps ES2022 order, and a program with no tsconfig is unchanged.
  • importsNotUsedAsValues and preserveValueImports for the import elision of Drop the imports tsc drops in projects that do not set verbatimModuleSyntax #3171. TypeScript 5.5 removed them and tsgo's config parser drops them, so they are read from the tsconfig's own text.

Named instead of followed, once per setting (SC0016, new):

  • Legacy decorators (experimentalDecorators): one diagnostic at the first decorator, with the count, instead of one TypeScript error per decorator (60 in class-validator). A class decorator of one parameter behaves the same under both decorator semantics and still compiles, unless emitDecoratorMetadata is set.
  • module: commonjs: import x = require() and export = report one SC0016 instead of TS1202/TS1203 per site, and "Cannot find namespace" on a package's default import explains that the project reads the package's require entry while an ES module resolves its import entry.
  • No strict: SC0002 now says the tsconfig "does not enable" strict when it is simply silent about it.

Packages that run in a Node island (added with 42b444d):

  • A package's own declarations still decide what is an island value. Loading the project's declarations also loads what the project says about packages. A project that types an untyped package itself (declare module "pkg/lib/core.js" { … } in a .d.ts its tsconfig includes, or in a typings directory named by typeRoots and types), or that augments an interface of a package (declare module "pkg" { interface Api { extra(): number } }), then types an island package's imports from a file the island does not own, and the boundary no longer saw island values: on 05c509c the dynamic import's .then was refused (SC2020) and the default export was refused at every use (SC8010). A declare module "<name>" block now declares the module it names: when that is a module of an island package, the block's declarations are the island's, wherever it is written. An island object typed by a global interface of the project's file (outside the block, with no native representation) stays a handle where the program only uses it through island operations, the rule arrays, Maps and Sets the island does not declare already follow; any other use of it is still refused by name.
  • The same rule fixes a failure main has today: an augmentation written in a program module made the augmented interface a native type, and the program built and then threw TypeError: expected … from a Node island at run time.
  • Reading .constructor of an island object (v.constructor.name) is an island operation instead of SC2020: 29 entries of the island value matrix (tests/harness/island-value-matrix/expected.json), all …ctor: refused:SC2020, are gone; nothing is added.

lib stays scriptc's own (never below ES2025, no DOM). Of TypeScript 7's ESNext slices, disposable, array and error are already loaded; collection, typedarrays, temporal and date describe APIs Node 24 does not have; intl refers to Temporal's types; sharedmemory (Atomics.pause) is the one slice Node 24 ships that is not loaded, and is left for a change that also checks the runtime. The two projects that failed "on lib" declare the missing members themselves in files their tsconfig includes, which is the first point above.

Proof

  • 8923-tsc-class-fields-target (// @tsc-emit, target: es2019): field initializers that read parameter properties directly and through a method, in a base and a derived class; the oracle is tsc's own output under Node.
  • 8924-project-ambient-declarations: global types from a .d.ts the tsconfig includes and nothing imports.
  • Island packages, each forced into an island by scriptc.packages, differential against Node, plain and sanitized: 8930-node-island-project-ambient-module (the project's .d.ts declares global interfaces and exact and pattern declare module blocks for an untyped CommonJS package: the shape pi has for highlight.js), 8931-node-island-module-augmentation (an augmentation in a project .d.ts and one in a program module), 8932-node-island-typeroots-ambient (typeRoots and types; interfaces declared in the blocks, held in bindings, parameters and results), 8933-node-island-typeroots-attypes (@types/<name> named in types), 8935-node-island-tsconfig-imports (bundled types: default, named and namespace imports, the package's types on native values, beside a project global .d.ts), 8939-node-island-tsc-elision (// @tsc-emit: Drop the imports tsc drops in projects that do not set verbatimModuleSyntax #3171's elision over island imports; an island module whose only import is unused is not loaded, as in tsc's output).
  • 8923-tsc-class-fields-target also pins "id" in this in a field initializer for a parameter property id (true when parameter properties are assigned first; review thread).
  • Diagnostics snapshots: tsconfig-legacy-decorators, tsconfig-commonjs-import-equals, tsconfig-commonjs-package-namespace, tsconfig-strict-unset, tsconfig-target-field-order (the TS2729 that stays).
  • The 19 open-source projects, as cloned, before and after (compiler built from each tree, same sandbox):
project main with #3171 and this PR
concurrently preflight: SC0001 ×6 (Intl members it declares itself), SC1010 ×4 passes preflight (then a compiler loop, already known from a hand-patched run)
sucrase preflight: TS2729 ×2, SC1013 preflight: one TS2367 that TypeScript 7 reports and the project's TypeScript 5.0 does not, SC1013
loxdown preflight: SC1016 ×43, TS2729 ×2 SC1016 ×6 (real cycles), one TS2322 of TypeScript 7 vs 3.8
yaml preflight: SC0001 ×8 SC0001 ×4, all Uint8Array.fromBase64, which Node 24 does not have
class-validator sample preflight: SC0001 ×60 SC0016 ×1 and the sample's own 47 strictness errors
nestjs starter lowering: SC1090 ×3, SC2013 preflight: SC0016 ×1, SC2013
tsc-alias preflight: SC0002, SC0001 ×7, SC1013 ×7 SC0002, SC0016 ×1, SC1013 ×7
barrelsby preflight: "Cannot find namespace 'Yargs'" the same error with the explanation
gravlax SC1016 and 13 lowering errors the 13 lowering errors
the other 10 unchanged (1 builds, 8 refused in lowering on unrelated gaps, 1 does not finish analysis)

Zero-cost gate

--gate-corpus=all, head 363651c (this stack with main 6cc6787 merged in) against base 6cc6787: PASS, 5246 identical, 103 fail on both sides, 2 allowed, no runtime-only rows; the self-hosted compiler builds with the candidate; compile time −0.58% [−2.73%, +0.34%], fixtures −0.21% [−1.50%, +1.08%], bench +0.00% [−1.30%, +1.08%], all within tolerance. The two allowed programs are #3171's (corpus/6120-type-only-import-graph as written on main, fixture/llvm-layouts), with --gate-allow='^import \{ type \w+(, type \w+)* \} from'; this PR adds none: no corpus program, benchmark or fixture on main has a tsconfig with ambient declarations, a types entry other than node, or a target below ES2022. The gate builds without a libnode pack, so island programs are skipped on both sides; the lanes and the island suites below cover them. An earlier round (05c509c against 960d2da, before the island change) gave 5202 identical, 96 fail on both sides, the same 2 allowed.

Lanes and bootstraps

Both full lanes in Sandboxes on 363651c: plain 22,106 passed, sanitized 18,873 passed, the plain and the sanitized native bootstrap included. The only failures, 8 in each lane, were eight cells of tests/harness/island-value-matrix.test.ts, whose table still listed v.constructor.name on an island object as refused; f448878 removes those 29 entries (no entry added), and the affected cells pass against the committed table, plain and sanitized. With a libnode pack assembled from this branch: the eight tests/harness/node-island*.test.ts suites (105 tests) and the 91 island corpus programs pass, plain and sanitized. pnpm lint, pnpm format:check, pnpm test:ts7 --baselines-only and the tsgo/tsc-ts identity check for the new programs pass. Two earlier rounds of both lanes ran on this stack before the island change (first plain 21,561 and sanitized 18,334 passed, 0 failed; second 21,616 and 18,383 with seven compile timeouts on a slow pool that pass alone).

pi

pi's root tsconfig sets verbatimModuleSyntax and target: ES2024, so import elision and field order do not apply to it; its include names five ambient .d.ts files, which are now loaded. One build each on the same 32 vCPU sandbox (Intel Xeon @ 2.50GHz): main 62 errors in 1,903.8 s, this stack 62 errors in 1,839.1 s. One site changes its first diagnostic: void import("highlight.js/lib/index.js").then(…) is now typed by the project's own declare module, and reports SC2020 for .then on that promise where it reported SC1090 for the dynamic import of a CommonJS module; a probe that awaits the same import is refused with the same SC1090 by both compilers. Peak RSS was 8.6 GB on main and 10.3 GB on this stack in that single pair of runs; I did not find the cause, and it is one sample.

With highlight.js as a Node island (the build's own decision when the package is not in --npm-static), pi's syntax-highlight.ts built alone showed what the island change fixes. Main reports one unrelated error for it (SC1090, an island package's default exports as object-literal values). 05c509c added SC8010 at the use of the package's default export, which pi's own highlight-js.d.ts types with a global interface (a full build of pi with that package as an island showed it at all three uses, with SC2020 on .then of the dynamic import). On this head the file's diagnostics are main's again, and with that object literal written as arrows (the scratch edit the running pi uses) it compiles, as it does on main (same sandbox, 32 vCPU, Intel Xeon @ 2.90GHz; a 3 s build).

Known differences that remain (recorded, not fixed here)

  • A class field declared without an initializer is an own property holding undefined, as in ES2022; tsc's output for a target below ES2022 creates no property, so Object.keys of such an instance differs from the project's own build.
  • A tsconfig silent about target under TypeScript 5 (default ES5) gets ES2022 order.
  • Interfaces declared in a project's own .d.ts are loaded but are not static record types; values typed by them are checked-dynamic (SC6104).
  • An island object typed by a global interface of the project's own .d.ts is used where the island hands it over (read, called, compared with undefined, passed back); storing it natively is SC8010 by name, main's rule for an island object typed as a program record. Declaring the type inside the declare module block makes it the island's type, a handle everywhere.
  • structuredClone of an island-made object into a record type declared in the project's own .d.ts builds and throws a TypeError at run time where Node copies; the same with the type declared in a program module works. Recorded with a repro for the island conversions work (islands: a value an island made passes the checked conversions #3029).
  • Checker options still come from the entry's tsconfig for the whole program; only import elision, class-field order and the decorator check are per file.

🤖 Generated with Claude Code

cramforce and others added 2 commits November 6, 2026 22:10
… field order

The declaration files a tsconfig's files and include name, and the packages
its types names, join the program: a project's own global declarations are
part of its type world.

A file whose tsconfig compiles class fields in the constructor (target below
ES2022, or useDefineForClassFields: false) assigns its parameter properties
before its field initializers run, as tsc's output does, so an initializer
may read one. The tsconfig is the nearest one above each file.

Settings that keep one meaning are named once instead of surfacing as
TypeScript errors: legacy decorators and the CommonJS-only import and export
forms report SC0016, and a tsconfig that is silent about strict says so in
SC0002. importsNotUsedAsValues and preserveValueImports, which tsgo's config
parser drops, are read from the tsconfig's own text.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… entry

A project that sets module to commonjs types a package through its require
entry, where export = can merge a namespace of types into the value. The
TypeScript files are ES modules for Node and for scriptc, which resolves the
import entry; 'Cannot find namespace' on such an import now says so.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@vercel

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Nov 7, 2026 10:53am UTC

cramforce and others added 4 commits November 7, 2026 01:08
Co-Authored-By: Claude Code <noreply@anthropic.com>
…es its modules

A project's own declaration files now join the program, and a project that
types an untyped package itself (`declare module "pkg/lib/core.js"`) then
types an island package's imports from a file the island does not own. The
boundary called a type the island's only when every declaration of it was in
the package's directory or its @types twin, so the module's namespace became
a native type (`import("pkg/…").then` was refused) and the default export
was refused at every use. An augmentation of a package's interface broke the
same way, also when it is written in a program module.

A `declare module "<name>"` block declares the module it names: when that is
a module of an island package, its declarations are the island's, wherever
the block is written (the project's .d.ts, a typeRoots package, a program
module, another package's types). The namespace, the interfaces declared in
the block, and an interface the block augments are island types.

An island object whose type has no native representation and that the island
does not declare (a global interface of the project's file, named by its
block) stays a handle where the program only uses it through island
operations, as arrays, Maps and Sets the island does not declare already do.
Any other use is still refused by name.

Co-Authored-By: Claude Code <noreply@anthropic.com>
In a file whose tsconfig compiles class fields in the constructor, the
parameter properties are assigned before the field initializers run, so
`"id" in this` in an initializer is true for a parameter property `id`. The
test still answered false, the answer for ES2022 class fields, where the
parameter properties are assigned after every initializer.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… value matrix

`v.constructor.name` on an island object was refused (SC2020): the read's
type, Function, has no native representation, so the lowering did not treat
`v.constructor` as an island value and looked for a native `Function.name`.
An island object of such a type now stays a handle under island-only use, so
the read is an island operation and answers as Node does. The 29 entries
`…ctor: refused:SC2020` of eight island-class, island-subclass and
island-error-class cells are gone, plain and sanitized; nothing is added.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

This PR regressed programs that use an npm package as a Node island; fixed and pinned on this branch (42b444d, f448878). The branch also has main 6cc6787 merged in (through #3171's branch, so the diff against its base is unchanged), and the review thread about "x" in this is fixed in 363651c.

What regressed. Not packages typed by their own declarations (bundled, or @types): those behaved as on main throughout. The regression needed the project to declare the island package's modules itself, which this PR is the first to load:

// types/paint-kit.d.ts, named by the tsconfig's "include"
interface PaintKit { has(name: string): boolean; list(): string[] }
declare module "paint-kit/lib/core.js" { const kit: PaintKit; export default kit; }
declare module "paint-kit/lib/index.js" { const kit: PaintKit; export default kit; }
import kit from "paint-kit/lib/core.js";      // paint-kit runs in a Node island
kit.has("bold");                              // SC8010 on 05c509c46, at every use of `kit`
void import("paint-kit/lib/index.js").then((mod) => mod.default.has("soft")); // SC2020 on `.then`

Main compiles this (the import is any there, with any package opted into --npm-static) and matches Node; 05c509c refused it with SC8010 at each use of the default export and SC2020 on .then. An exact-name declare module block wins in the checker over the file the import resolves to, and the island boundary called a type the island's only when every declaration of it was in the package's directory or its @types twin. So the module's namespace became a native type, and the default export's type, an interface with methods and no native representation, was refused at every read. It did not matter whether the package was marked as an island or became one by the build's own decision. pi has exactly this file for highlight.js.

An augmentation of an island package's interface (declare module "pkg" { interface Api { extra(): number } }) broke the same way. Main has that one too when the augmentation is written in a program module: it builds and then throws TypeError: expected … from a Node island at run time.

The fix.

  • A declare module "<name>" block declares the module it names. When that is a module of an island package, its declarations are the island's, wherever the block is written: the project's .d.ts, a typeRoots package, a program module, another package's types (node-islands/boundary.ts).
  • An island object whose type has no native representation and that the island does not declare (the global interface above) stays a handle where the program only uses it through island operations. Arrays, Maps and Sets the island does not declare already did; any other use is still SC8010 (lower-node-island.ts).

A side effect of the second rule: v.constructor.name on an island object is an island operation instead of SC2020, which removes 29 …ctor: refused:SC2020 entries from the island value matrix and adds none.

Pinned (each differential against Node with the package forced into an island, plain and sanitized):

program what it holds
8930-node-island-project-ambient-module the shape above: a project .d.ts with global interfaces and exact and pattern declare module blocks for an untyped CommonJS package
8931-node-island-module-augmentation an augmentation in a project .d.ts and one in a program module
8932-node-island-typeroots-ambient typeRoots and types; interfaces declared inside the blocks, held in bindings, parameters and results
8933-node-island-typeroots-attypes @types/<name> named in types
8935-node-island-tsconfig-imports bundled types: default, named and namespace imports, the package's types on native values, beside a project global .d.ts
8939-node-island-tsc-elision #3171's elision over island imports (// @tsc-emit): an island module whose only import is unused is not loaded

Ran on this head. Zero-cost gate with --gate-corpus=all against 6cc6787: PASS, 5246 identical, 103 fail on both sides, 2 allowed (#3171's two), no runtime-only rows. Both lanes: plain 22,106 passed, sanitized 18,873 passed, both native bootstraps included; the only failures were the eight value-matrix cells that f448878 updates, and those pass against the committed table both ways. The island harness suites (105 tests) and the 91 island corpus programs pass with a libnode pack assembled from this branch, plain and sanitized. The description has the details and two known differences that remain.

With `typeRoots` configured, tsc resolves a `types` name under each root as
a file or a directory, as it does a module: `types: ["measures"]` with
`typeRoots: ["./typings"]` names typings/measures.d.ts before it names a
typings/measures directory. The lookup here only knew the directory form
(its package.json `types`/`typings`, then index.d.ts), so a project whose
entry is a declaration file of its own lost those declarations: tsc accepts
the project and Node runs it, and the build failed with "Cannot find name"
for every global the file declares.

typeRootEntry tries `<root>/<name>.d.ts` first.
tests/corpus/9318-typeroots-declaration-file has one entry of each form.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@cramforce

Copy link
Copy Markdown
Contributor Author

Superseded by #3386 (train cli 5), which carries this PR's commits unchanged, with the comment-edit cache fix and the project-resolution change on top; it was validated there as one tree. Closing in its favour.

@cramforce cramforce closed this Nov 8, 2026
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
…port-elision-reads

The island value matrix keeps main's pins here; it is regenerated on the merged tree.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
…ge in the tree

The merge kept main's pins. With vercel-labs#3207 in the tree an island handle no
longer refuses a read of its constructor, and the update mode of the
matrix drops the 29 refused:SC2020 entries for those reads. Nothing else
moves.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
… no longer refused

Reading `.constructor` of an island object is an island operation since
the tsconfig adoption this branch carries (vercel-labs#3207); main's table, re-pinned
since, still lists the 29 `.ctor` observations of eight cells as
`refused:SC2020`. Regenerated for those cells on this tree
(island-class.i, .u, .w, island-error-class.i, .u, island-subclass.i, .p,
.u): the 29 entries go, nothing is added. Also formats resolve.test.ts.

Co-Authored-By: Claude Code <noreply@anthropic.com>
yowainwright pushed a commit to yowainwright/scriptc that referenced this pull request Nov 8, 2026
…arations resolve as its tsc resolves them

The branch carries vercel-labs#3171 (import elision) and vercel-labs#3207 (the project's tsconfig for ambient declarations, types and class field order) with the comment-edit cache fix of fix/import-elision-reads.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 4e8d9556 Deployed Nov 7, 2026 by vercel[bot]
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