ADR-001: Compile-Before-Publish TypeScript Strategy
Status
Accepted -- 2026-02-16 (supersedes original ADR-001 from 2025-12-22)
Context
Original Decision
The original ADR-001 (2025-12-22) chose to publish @connectum/* packages as raw .ts source files to npm, relying on Node.js 25.2.0+ stable type stripping at runtime. The rationale was zero build step, instant startup, and simplified CI/CD.
After real-world feedback and deeper analysis, this decision has been revised.
Node.js Maintainer Feedback
A Node.js core maintainer provided the following critical feedback on the "publish .ts source" approach:
Node.js actively blocks type stripping in
node_modules-- this is an intentional design decision, not a temporary limitation. See the official documentation.TypeScript is not backward-compatible -- TypeScript regularly introduces breaking changes in minor versions. Real-world examples include
noble/hashesanduint8arraybreakage, as well as legacy decorators vs. TC39 Stage 3 decorators incompatibilities.Each package must control its own TypeScript version -- a package should compile with the TypeScript version it was tested against and publish the resulting JavaScript. Forcing consumers to strip types at runtime couples them to the publisher's TypeScript version.
JavaScript is permanently backward-compatible -- once valid JS is published, it works forever. TypeScript source does not have this guarantee.
Official position -- Node.js documentation explicitly states that type stripping should not be used for dependencies in
node_modules.Practical breakage patterns -- decorator semantics, enum compilation changes, and import resolution differences across TypeScript versions create silent failures that are difficult to diagnose.
Loader Propagation Issues
The raw .ts publishing approach required consumers to register a custom loader (@connectum/core/register) or use --import flags. This created several problems:
- Worker threads do not inherit
--importhooks fork()/spawn()do not propagate loader configuration- APM instrumentation tools (OpenTelemetry, Datadog, New Relic) may not propagate hooks correctly
- Test runners and build tools may strip or ignore custom loaders
These issues made the raw .ts approach unreliable in production environments with complex process hierarchies.
Industry-Standard Practice
Compile-before-publish is the established pattern used by virtually all major TypeScript packages in the ecosystem. Frameworks and libraries such as tRPC, Fastify, Effect, Drizzle ORM, and Hono all develop in TypeScript but publish compiled .js + .d.ts + source maps. Common tooling includes:
- tsup (esbuild-powered) or unbuild (rollup-powered) for fast compilation
- ESM as the primary output format
declarationMap: truefor IDE jump-to-source navigation- Turborepo or Nx for monorepo build orchestration
This pattern is well-proven at scale across monorepos with dozens of packages.
Decision
Compile-before-publish with tsup: develop in .ts, publish .js + .d.ts + source maps to npm.
Build Pipeline
| Tool | Purpose |
|---|---|
| tsup | Compile TS to JS (esbuild under the hood) |
| tsc | Type checking only (--noEmit) |
| Turborepo | Orchestrate build tasks across monorepo |
Output characteristics:
- ESM only (
type: "module") - Declaration files (
.d.ts) for consumer type checking - Declaration maps (
declarationMap: true) for IDE jump-to-source - Source maps (
.js.map) for debugging - No minification -- framework code should be readable
tsup Configuration
// tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
sourcemap: true,
clean: true,
minify: false,
})Package.json Template
{
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup",
"dev": "node --watch src/index.ts",
"typecheck": "tsc --noEmit",
"test": "node --test tests/**/*.test.ts"
}
}TypeScript Configuration
The tsconfig.json remains largely unchanged from the original ADR:
{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"declarationMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}What Is Preserved from the Original ADR
The following conventions remain unchanged:
erasableSyntaxOnly: true-- noenum, nonamespacewith runtime code, no parameter propertiesverbatimModuleSyntax: true-- explicitimport typerequired.tsextensions in import paths --rewriteRelativeImportExtensionsrewrites them to.jsduring buildnode src/index.tsfor local development -- type stripping works outsidenode_modulesnode --watch src/index.tsfor hot reload during developmenttsc --noEmitfor type checking- Node.js >= 25.2.0 for the development environment
- All syntax restrictions (no enum, no namespace, no parameter properties, no decorators, explicit
import type,package.json#importsfor path aliases)
What Changes
| Aspect | Before (Original ADR-001) | After (This ADR) |
|---|---|---|
| npm artifact | src/*.ts (raw source) | dist/*.js + dist/*.d.ts + dist/*.js.map |
| package.json exports | ./src/index.ts | ./dist/index.js |
| Build step | None | tsup before publish |
@connectum/core/register | Required for consumers | DEPRECATED (no longer needed) |
| Consumer Node.js requirement | >=25.2.0 | >=18.0.0 (any modern Node.js) |
| Consumer TypeScript coupling | Must match publisher's TS version | Decoupled via .d.ts |
| Development Node.js requirement | >=25.2.0 | >=25.2.0 (unchanged) |
Consequences
Positive
Broad Consumer Compatibility -- published JavaScript works on any Node.js >=18.0.0. Consumers are no longer forced to use Node.js 25.2.0+ at runtime.
No Loader Issues -- compiled JavaScript requires no custom loaders, hooks, or
--importflags. Worker threads,fork(), and APM tools work without special configuration.TypeScript Version Decoupling -- the framework controls which TypeScript version it compiles with. Consumers receive stable
.d.tsdeclarations that work with any compatible TypeScript version.Ecosystem Standard -- compile-before-publish is the established pattern used by virtually all major TypeScript packages (tRPC, Fastify, Effect, Drizzle ORM, Hono, etc.). This reduces surprise for consumers.
Permanent Backward Compatibility -- published JavaScript does not break across TypeScript or Node.js upgrades. Once published, it works forever.
IDE Experience Preserved --
declarationMap: trueenables jump-to-source navigation in IDEs, providing the same developer experience as raw.tssource.Development Workflow Unchanged -- developers still write
.ts, runnode src/index.tslocally, and usenode --watchfor hot reload. The build step only runs before publish.
Negative
Added Build Step --
tsupmust run before publishing. This adds ~2-5 seconds per package to the CI/CD pipeline. Mitigated by Turborepo caching and parallel builds.dist/Directory -- each package now has adist/folder that must be gitignored and managed. Mitigated by.gitignoreandfilesfield inpackage.json.Build Dependency -- tsup (and transitively esbuild) is added as a dev dependency. Mitigated by the fact that tsup is a well-maintained, widely-used build tool with minimal dependencies.
Source Not Directly Readable in
node_modules-- consumers see compiled JS innode_modulesinstead of TypeScript source. Mitigated by source maps and declaration maps for debugging and navigation.
Risks
tsup/esbuild compatibility -- if tsup introduces a breaking change, it could affect the build pipeline. Mitigated by pinning versions and using Turborepo's deterministic builds.
Declaration file accuracy --
.d.tsgeneration can occasionally produce incorrect types for complex TypeScript patterns. Mitigated bytsc --noEmittype checking and integration tests.
Migration Plan
Phase 1: Add Build Tooling
- Add
tsupas a dev dependency to each@connectum/*package - Create
tsup.config.tsin each package - Add
buildscript to eachpackage.json - Add
dist/to.gitignore - Update Turborepo pipeline to include
buildtask
Phase 2: Update Package Exports
- Change
package.jsonexports from./src/index.tsto./dist/index.js - Add
typesfield pointing to./dist/index.d.ts - Update
filesfield to include onlydist - Add
declarationMap: truetotsconfig.json
Phase 3: Deprecate Register Hook
- Mark
@connectum/core/registeras deprecated with a console warning - Update documentation to remove loader registration instructions
- Remove register entrypoint in the next major version
Phase 4: Update CI/CD and Documentation
- Update GitHub Actions workflows to run
pnpm buildbefore publish - Update Changesets publish workflow to include build step
- Update all documentation, guides, and examples
- Update
enginesfield: keep>=25.2.0for development, document>=18.0.0for consumers
Alternatives Considered
Alternative 1: Keep Raw .ts Publishing (Original ADR-001)
Rejected. While appealing in theory (zero build step), this approach is explicitly blocked by Node.js in node_modules, couples consumers to a specific TypeScript version, and creates unreliable behavior with worker threads and process forking.
Alternative 2: tsc Compilation
Considered but not chosen. Standard tsc compilation works but is significantly slower than tsup/esbuild for the compilation step. It also does not support bundling or tree-shaking if needed in the future. tsup provides a faster, more flexible build pipeline while still using tsc for type checking.
Alternative 3: Dual ESM + CJS Publishing
Deferred. Publishing both ESM and CJS formats increases package size and complexity. Since Connectum targets modern Node.js environments, ESM-only is sufficient. CJS support can be added later via tsup's format: ['esm', 'cjs'] if consumer demand justifies it.
Alternative 4: SWC-based Compilation
Considered but not chosen. SWC is faster than esbuild for some workloads but has less mature .d.ts generation. tsup's esbuild backend is fast enough for Connectum's package sizes, and tsup's built-in dts support simplifies the pipeline.
Alternative 5: Bun / Deno Runtime
Rejected. Both runtimes have native TypeScript support but would abandon the Node.js ecosystem and ConnectRPC compatibility. The Node.js ecosystem is a core requirement for Connectum.
References
- Node.js -- Type Stripping in Dependencies -- official documentation on why type stripping is blocked in
node_modules - Node.js TypeScript Documentation -- full TypeScript support documentation
- tsup Documentation -- build tool used for compilation
- TypeScript 5.8 --
rewriteRelativeImportExtensions-- compiler option for.tsto.jsimport rewriting - Turborepo Documentation -- monorepo build orchestration
Changelog
| Date | Author | Change |
|---|---|---|
| 2025-12-22 | Claude | Original ADR: Native TypeScript (raw .ts publishing) |
| 2026-02-16 | Claude | Revised: Compile-before-publish with tsup (this version) |
