Skip to content

ADR-001: Compile-Before-Publish TypeScript Strategy ​

Status ​

Accepted -- 2026-02-16 (supersedes original ADR-001 from 2025-12-22)

Current requirements: The consumer floor below was later raised to >=22.13.0, and the framework development floor is now >=26.0.0. See Runtime Compatibility and Migrating to 1.0. The historical decision body, including its estimates and original requirements, is preserved below.

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:

  1. Node.js actively blocks type stripping in node_modules -- this is an intentional design decision, not a temporary limitation. See the official documentation.

  2. TypeScript is not backward-compatible -- TypeScript regularly introduces breaking changes in minor versions. Real-world examples include noble/hashes and uint8array breakage, as well as legacy decorators vs. TC39 Stage 3 decorators incompatibilities.

  3. 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.

  4. JavaScript is permanently backward-compatible -- once valid JS is published, it works forever. TypeScript source does not have this guarantee.

  5. Official position -- Node.js documentation explicitly states that type stripping should not be used for dependencies in node_modules.

  6. 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 --import hooks
  • 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: true for 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 ​

ToolPurpose
tsupCompile TS to JS (esbuild under the hood)
tscType checking only (--noEmit)
TurborepoOrchestrate 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 ​

typescript
// 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 ​

json
{
  "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:

json
{
  "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 -- no enum, no namespace with runtime code, no parameter properties
  • verbatimModuleSyntax: true -- explicit import type required
  • .ts extensions in import paths -- rewriteRelativeImportExtensions rewrites them to .js during build
  • node src/index.ts for local development -- type stripping works outside node_modules
  • node --watch src/index.ts for hot reload during development
  • tsc --noEmit for 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#imports for path aliases)

What Changes ​

AspectBefore (Original ADR-001)After (This ADR)
npm artifactsrc/*.ts (raw source)dist/*.js + dist/*.d.ts + dist/*.js.map
package.json exports./src/index.ts./dist/index.js
Build stepNonetsup before publish
@connectum/core/registerRequired for consumersDEPRECATED (no longer needed)
Consumer Node.js requirement>=25.2.0>=18.0.0 (any modern Node.js)
Consumer TypeScript couplingMust match publisher's TS versionDecoupled via .d.ts
Development Node.js requirement>=25.2.0>=25.2.0 (unchanged)

Consequences ​

Positive ​

  1. 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.

  2. No Loader Issues -- compiled JavaScript requires no custom loaders, hooks, or --import flags. Worker threads, fork(), and APM tools work without special configuration.

  3. TypeScript Version Decoupling -- the framework controls which TypeScript version it compiles with. Consumers receive stable .d.ts declarations that work with any compatible TypeScript version.

  4. 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.

  5. Permanent Backward Compatibility -- published JavaScript does not break across TypeScript or Node.js upgrades. Once published, it works forever.

  6. IDE Experience Preserved -- declarationMap: true enables jump-to-source navigation in IDEs, providing the same developer experience as raw .ts source.

  7. Development Workflow Unchanged -- developers still write .ts, run node src/index.ts locally, and use node --watch for hot reload. The build step only runs before publish.

Negative ​

  1. Added Build Step -- tsup must run before publishing. This adds ~2-5 seconds per package to the CI/CD pipeline. Mitigated by Turborepo caching and parallel builds.

  2. dist/ Directory -- each package now has a dist/ folder that must be gitignored and managed. Mitigated by .gitignore and files field in package.json.

  3. 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.

  4. Source Not Directly Readable in node_modules -- consumers see compiled JS in node_modules instead of TypeScript source. Mitigated by source maps and declaration maps for debugging and navigation.

Risks ​

  1. 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.

  2. Declaration file accuracy -- .d.ts generation can occasionally produce incorrect types for complex TypeScript patterns. Mitigated by tsc --noEmit type checking and integration tests.

Migration Plan ​

Phase 1: Add Build Tooling ​

  • Add tsup as a dev dependency to each @connectum/* package
  • Create tsup.config.ts in each package
  • Add build script to each package.json
  • Add dist/ to .gitignore
  • Update Turborepo pipeline to include build task

Phase 2: Update Package Exports ​

  • Change package.json exports from ./src/index.ts to ./dist/index.js
  • Add types field pointing to ./dist/index.d.ts
  • Update files field to include only dist
  • Add declarationMap: true to tsconfig.json

Phase 3: Deprecate Register Hook ​

  • Mark @connectum/core/register as 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 build before publish
  • Update Changesets publish workflow to include build step
  • Update all documentation, guides, and examples
  • Update engines field: keep >=25.2.0 for development, document >=18.0.0 for 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 ​

  1. Node.js -- Type Stripping in Dependencies -- official documentation on why type stripping is blocked in node_modules
  2. Node.js TypeScript Documentation -- full TypeScript support documentation
  3. tsup Documentation -- build tool used for compilation
  4. TypeScript 5.8 -- rewriteRelativeImportExtensions -- compiler option for .ts to .js import rewriting
  5. Turborepo Documentation -- monorepo build orchestration

Changelog ​

DateAuthorChange
2025-12-22ClaudeOriginal ADR: Native TypeScript (raw .ts publishing)
2026-02-16ClaudeRevised: Compile-before-publish with tsup (this version)
2026-10-08Documentation reviewReplaced the Update note about the consumer Node.js floor with a pointer to the current requirements (consumer >=22.13.0, framework development >=26.0.0); added page metadata. Decision text unchanged.