5.5 KiB
RFC: TSC-first build and one tsconfig
Status: implemented (accepted 2026-06-20)
Context
The current TypeScript build and typecheck setup had these issues:
buildusedtscto transform.tsto.d.tsfiles for packages underpackages/<group>/<pkg>andvendor/*, and then usedtsdownto transform.tsto bundled.jsfiles. This made two tools do TypeScript transform.typechecktended to validate packages, vendor source, examples, tests, and scripts through one root typecheck config.
The goal is to make build and typecheck use matching tsconfig boundaries and TypeScript resolution/transform behavior. Build should generate .js, .d.ts, .js.map, and .d.ts.map through one compiler and config, so publish output and type validation stay consistent.
Validation found several concrete technical issues and possible routes:
tsdownusesoxcto transform TypeScript, which is not the same behavior astsc.- Bundled
.d.tsemitted bytsdownconflicts with Cordis' internal relative module augmentation shape. - The tsc output is affected by
allowImportingTsExtensions, so we need to ensure that generated.jsfiles do not import.tsfiles and generated.d.tsfiles keep explicit relative specifiers that NodeNext/Node16 accepts. Therefore, in-package relative imports use explicit.tsspecifiers in TypeScript source andrewriteRelativeImportExtensionsrewrites those specifiers to.jsin emitted JS. - Bundled
.jsemitted bytsdownis not the same behavior as per-file.jsemitted bytsc -b, such as decorator transform behavior.
- Bundled
vendor/*/src, examples, tests, and scripts cannot all be plain-included in one root strict program.- Directly typechecking
vendor/*/srcunder the root strict config triggers many type errors outside this project's ownership. - Package dependencies under
packages/*/*onvendorare resolved to thevendor/*/libfor different tsconfig strictness.
- Directly typechecking
Decision
In-package relative imports use explicit .ts specifiers.
pnpm run build is a two-stage build:
- Stage 1:
tsc -b tsconfig.build.jsonemits per-module.js, declarations.d.ts, JS sourcemaps.js.map, and declaration sourcemaps.d.ts.mapinto each package'slib/types. This is the authoritative TypeScript compilation result. For publish we keep.d.ts/.d.ts.mapand ignore.js/.js.map.- The build project uses the project-reference graph that
tsc -bcompiles. For example, roottsconfig.build.jsonreferences package and vendor tsconfigs. It validates and emits package/vendor build results.
- The build project uses the project-reference graph that
- Stage 2: a bundler reads the emitted JS under
lib/typesand writes the bundled runtime entry aslib/index.jsorlib/index.mjs(follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations.
tsdown is no longer the owner of TypeScript compilation or declaration output.
pnpm run typecheck runs build mode over the root tsconfig.json.
- The root
tsconfig.jsonis the single development/typecheck project. It typechecks examples, tests, and scripts withnoEmit, and validates package/vendor source through references. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their
lib/typesoutputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owningpackages/*/*/tsconfig.jsonorvendor/*/tsconfig.json. - The root no-emit project disables
rewriteRelativeImportExtensions; it emits nothing and includes tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled.
The command orchestration shape is:
pnpm run build:
tsc -b tsconfig.build.json
tsdown
pnpm run verify-node-next-types:
tsx scripts/verify-node-next-types.ts
pnpm run typecheck:
tsc -b tsconfig.json
pnpm run demo:* still runs src directly through tsx and root paths, without a compile step.
Consequences
Build responsibilities are clearer:
- Each module under
packages/<group>/<pkg>andvendor/*has one local tsconfig for build, typecheck, and tools that run source directly, such astsxandvitest. - The
buildcommand usestsconfig.build.json.tsc -bowns the publishable per-module.jsand.d.tsoutput, and the bundler owns onlylib/index.*.lib/types/*.d.tsand.d.ts.mapare the publish declaration output.lib/types/*.d.tsuses explicit.tsrelative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling.d.tsfiles.lib/types/*.jsis only a bundler input and must not be used as a runtime entry or public import target.lib/index.*is the publish runtime output and is generated by the bundler, currentlytsdown.
pnpm run verify-node-next-typesscans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer withmoduleResolution: "NodeNext"against the builttypes/exportssurface, so declaration specifier regressions fail before publish.- The
typecheckcommand usestsconfig.json. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior asbuild. Package and vendor source stays behind project-reference boundaries.
The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired.