Without a purpose-built tsconfig.json layout, TypeScript library builds fail in predictable ways: path aliases leak into published .d.ts files and cause MODULE_NOT_FOUND in consumers, a single outDir mixes CJS and ESM artifacts triggering ERR_REQUIRE_ESM at runtime, and type generation blocks JavaScript transpilation so CI takes twice as long. These failures surface in Node.js 18+ where native ESM enforcement became strict, and compound when bundlers like Webpack 5 or Vite apply their own moduleResolution assumptions against your emitted declarations.

A library’s tsconfig.json must serve two different masters: local development (fast feedback, path aliases, lenient checks) and published artifacts (strict resolution, no leaked aliases, environment-appropriate module settings). The solution is a shared base config with format-specific overrides and a separate types-only emit pass.

Prerequisites

Build Architecture Diagram

tsconfig.json split build architecture for dual ESM/CJS output Diagram showing tsconfig.base.json extended by tsconfig.cjs.json, tsconfig.esm.json, and tsconfig.types.json, each emitting to separate output directories fed into the package exports map. tsconfig.base.json strict · target · verbatimModuleSyntax tsconfig.cjs.json module: CommonJS → dist/cjs tsconfig.esm.json module: ESNext → dist/esm tsconfig.types.json emitDeclarationOnly → dist/types dist/cjs/*.js + .d.ts dist/esm/*.js + .d.ts dist/types/*.d.ts + .d.ts.map ↑ all three feed the package.json exports map

Canonical Configuration Block

Save this as tsconfig.base.json — the single source of truth for compiler strictness shared by every format build:

{
  "compilerOptions": {
    // Emit ES2022 syntax: top-level await, class fields, Object.hasOwn
    "target": "ES2022",

    // Full strict suite including strictNullChecks, noImplicitAny, strictFunctionTypes
    "strict": true,

    // Prevents import/export type erasure ambiguity; required for modern bundlers
    "verbatimModuleSyntax": true,

    // Bundler-friendly resolution: honours package.json exports without .js extensions
    "moduleResolution": "Bundler",

    // Allows parallel transpilation by esbuild/tsup without cross-file type info
    "isolatedModules": true,

    // Avoids false positives from @types/* in node_modules of consumers
    "skipLibCheck": true,

    // Prevents case-sensitivity bugs between macOS (case-insensitive) and Linux CI
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

Step-by-Step Implementation

Step 1 — Create format-specific override configs

tsconfig.base.json deliberately omits module and outDir. Each format config extends it and supplies those:

// tsconfig.cjs.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "./dist/cjs",
    "declaration": true,
    "declarationDir": "./dist/cjs/types"
  }
}
// tsconfig.esm.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "./dist/esm",
    "declaration": true,
    "declarationDir": "./dist/esm/types"
  }
}

Node.js determines module format from the file extension and the type field in package.json. Mixing CJS and ESM output into the same outDir means a .js file can be loaded as the wrong format at runtime — with no compile-time error. Always emit to dist/cjs/ and dist/esm/ separately, then map both in the exports field.

Expected diff after running tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json:

dist/
  cjs/
    index.js
    types/
      index.d.ts
  esm/
    index.js
    types/
      index.d.ts

Step 2 — Isolate path aliases from distribution builds

Internal path aliases (baseUrl and paths) are convenient during development but fatal when published. Consumers lack your workspace configuration, so leaked aliases produce immediate MODULE_NOT_FOUND errors.

Keep aliases in a dev-only config that is never passed to tsc for distribution:

// tsconfig.json  (IDE / editor / dev server only — NOT used by build scripts)
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@internal/*": ["src/*"]
    }
  }
}

Then run tsc-alias after each format build to rewrite any surviving aliases to relative paths:

{
  "scripts": {
    "build:cjs": "tsc -p tsconfig.cjs.json && tsc-alias -p tsconfig.cjs.json",
    "build:esm": "tsc -p tsconfig.esm.json && tsc-alias -p tsconfig.esm.json"
  }
}

Verify no aliases escaped into the output:

grep -r "@internal" dist/ && echo "ALIAS LEAK DETECTED" || echo "Clean — no leaked aliases"

For the full resolution picture see Path Mapping and Module Resolution Strategies.

Step 3 — Decouple type emission from JavaScript transpilation

emitDeclarationOnly: true generates .d.ts files without emitting JavaScript, so tsc and your JS bundler can run in parallel. This typically halves CI build time for medium-to-large libraries:

// tsconfig.types.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "emitDeclarationOnly": true,
    "declaration": true,
    "declarationMap": true,
    "declarationDir": "./dist/types",
    "rootDir": "./src"
  }
}

Parallel build script:

#!/usr/bin/env bash
set -euo pipefail

# Type declarations (tsc) and JavaScript bundles (esbuild) run concurrently
tsc -p tsconfig.types.json &
TSC_PID=$!

esbuild src/index.ts --bundle=false --format=esm --outdir=dist/esm --sourcemap &
ESM_PID=$!

esbuild src/index.ts --bundle=false --format=cjs --outdir=dist/cjs --sourcemap &
CJS_PID=$!

wait $TSC_PID $ESM_PID $CJS_PID
echo "Build complete."

HAZARD PREVENTION — missing declarationMap: Omitting declarationMap: true breaks “Go to Definition” across package boundaries in every IDE. Consumers clicking into your library see raw .d.ts content instead of your original TypeScript source. Always include it.

The full mechanics of declaration file output — including stripInternal, /** @internal */ JSDoc stripping, and .d.mts/.d.cts dual-extension patterns — are covered in Declaration File Generation and Type Stripping.

Step 4 — Validate types across runtime environments

Different runtime environments expose different global types. A single tsc --noEmit with browser lib settings will not catch process.env usage that breaks in browsers, nor DOM API calls that fail in Node.js. Run a matrix validation in CI:

# .github/workflows/type-check.yml
name: Cross-Environment Type Validation
on: [push, pull_request]

jobs:
  type-check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [node, browser]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: Validate ${{ matrix.env }} types
        run: |
          if [ "${{ matrix.env }}" == "browser" ]; then
            npx tsc --noEmit --lib ES2022,DOM --strict --moduleResolution bundler
          else
            npx tsc --noEmit --lib ES2022 --strict --moduleResolution nodenext
          fi

Always enforce strict: true in every validation gate. Disabling it allows implicit any types to appear in published declarations, causing type errors for consumers who do enable strict mode.

Step 5 — Wire the output into package.json exports

The three output directories each feed a different condition in the exports map. Condition key order matters — put types first, default last:

{
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/esm/index.js"
    }
  },
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "types": "./dist/types/index.d.ts"
}

Hazard Call-Outs

HAZARD PREVENTION — moduleResolution: "Node" in TypeScript 5: The legacy node resolution strategy does not honour package.json exports conditions. TypeScript will silently fall back to the main field, causing type mismatches when consumers use a bundler that resolves correctly. Switch to "Bundler" for library source files and "NodeNext" for Node-only CLIs.

HAZARD PREVENTION — skipLibCheck: true masking peer dependency issues: This flag tells TypeScript to skip type-checking in node_modules, which hides version conflicts between your @types/* declarations and those of your consumers. Run a separate CI job with skipLibCheck: false to surface these before publishing:

npx tsc -p tsconfig.base.json --skipLibCheck false --noEmit

HAZARD PREVENTION — slow incremental builds with declaration: true: When declaration is true and isolatedModules is false, TypeScript must analyse the full dependency graph for every file to emit declarations. Setting isolatedModules: true plus verbatimModuleSyntax lets parallel transpilers (esbuild, tsup) operate safely, and decoupling type emission via emitDeclarationOnly prevents tsc from repeating JS work.

HAZARD PREVENTION — ERR_REQUIRE_ESM from shared outDir: If tsconfig.cjs.json and tsconfig.esm.json share an outDir, the .js extension collision means one format’s file may overwrite the other. The missing or wrong-format file is then loaded at runtime and throws. Enforce dist/cjs/ and dist/esm/ separation with a lint script in your CI:

[ -f dist/cjs/index.js ] && [ -f dist/esm/index.js ] || (echo "Missing output file" && exit 1)

Tooling Validation

Run these commands after every build before publishing:

# 1. Structural type correctness
npx tsc -p tsconfig.types.json --noEmit

# 2. Published package health (exports, types, files fields)
npx publint

# 3. Whether declaration files are correct for each export condition
npx attw --pack .

# 4. Alias leak check
grep -r "@internal\|@src\|@lib" dist/ && echo "LEAKED ALIAS" || echo "Clean"

Sample publint pass output:

✔ No issues found

Sample attw pass output:

┌─────────────────────────────────────────────────────────┐
│ Package analysis                                        │
├──────────────────────┬──────────────────────────────────┤
│ node10               │ 🟢 (CJS) index.d.ts              │
│ node16 (from CJS)    │ 🟢 (CJS) index.d.ts              │
│ node16 (from ESM)    │ 🟢 (ESM) index.d.ts              │
│ bundler              │ 🟢 (ESM) index.d.ts              │
└──────────────────────┴──────────────────────────────────┘

Compatibility Matrix

TypeScript moduleResolution module (CJS) module (ESM) verbatimModuleSyntax Notes
4.7 node16 / nodenext CommonJS Node16 n/a (use importsNotUsedAsValues) First TS version with native ESM support
5.0 bundler CommonJS ESNext ✅ supported bundler resolution added; replaces node16 for library sources
5.2 bundler CommonJS ESNext ✅ recommended verbatimModuleSyntax stable; replaces importsNotUsedAsValues
5.4+ bundler CommonJS ESNext ✅ recommended .d.mts / .d.cts dual-extension declarations fully supported
Node.js Native ESM CJS interop exports map Minimum recommended
18 (EOL Apr 2025) No — EOL, no security patches
20 LTS Yes — current LTS baseline
22 Yes — active

Common Pitfalls

Issue Root Cause Resolution
Leaked baseUrl / paths in published .d.ts Compiler preserves workspace aliases in emitted declarations Restrict paths to dev-only tsconfig.json; run tsc-alias post-build
Slow incremental CI builds declaration: true forces full dependency graph analysis Enable isolatedModules + verbatimModuleSyntax; decouple via emitDeclarationOnly
Mixed formats → ERR_REQUIRE_ESM CJS and ESM share outDir — one format’s .js overwrites the other Enforce dist/cjs/ and dist/esm/ separation; map both in exports
skipLibCheck: true hides peer type conflicts Compiler skips node_modules .d.ts entirely Run a separate CI job with skipLibCheck: false
“Go to Definition” shows raw .d.ts, not source declarationMap omitted Add declarationMap: true to tsconfig.types.json
attw reports ❌ for node16 (from ESM) ESM output lacks .js extensions on relative imports Add "moduleResolution": "NodeNext" to tsconfig.esm.json for Node16-targeted builds; ensure bundler output adds extensions

Compiler Options That Reach the Consumer

Most tsconfig.json options affect only your own development experience. A smaller set is baked into what you publish, and those deserve a different level of scrutiny — changing one is a change to every consumer’s build.

Which compiler options escape into the package Local-only options such as noUnusedLocals and incremental affect the author's build. Published options such as target, module, declaration, strict and lib change the emitted JavaScript or declaration files that consumers receive. Two very different kinds of compiler option stays in your repository noUnusedLocals, noUnusedParameters incremental, tsBuildInfoFile pretty, diagnostics paths (compile-time only) change these freely — nobody outside the repo can tell except through faster builds ships to every consumer target — the syntax they receive module — how imports are emitted lib — which globals your types assume strict — the nullability of your API declaration + declarationMap — whether types and go-to-source work changing these is a release decision

strict belongs on the right-hand side for a reason authors often miss. Compiling a library without strict produces declarations in which nothing is nullable, and a consumer who does use strict mode inherits those over-confident types: your function that can return undefined is typed as always returning a value, and their null check looks unnecessary to the compiler. Publishing non-strict types into a strict ecosystem is a correctness problem, not a style preference.

lib is similarly consequential. Listing "DOM" in a library that also runs on the server means your declarations reference browser globals, and a consumer compiling a Node-only project gets errors about types they have no reason to include. Where a package genuinely spans both, the honest arrangement is separate declaration outputs per environment behind separate conditions, not one declaration set that assumes the union of all globals.

target deserves a deliberate decision rather than a default. A lower target produces more compatible but larger output, with helper functions injected for down-levelled syntax. A higher target ships the syntax you wrote, which is smaller and faster to parse, and excludes runtimes that cannot parse it. Because consumers can always down-level your output with their own build but cannot up-level it, the usual advice is to target the oldest runtime you actually support in engines — and to keep those two numbers in agreement, since a target: ES2017 build alongside engines.node: ">=20" is simply shipping larger code for no one’s benefit.

Two Configurations, One Source Tree

The tension in a library tsconfig.json is that development wants everything included and the build wants only the published surface. One file cannot serve both without compromise, so the standard arrangement is a development config that includes tests and scripts, and a build config that extends it with the extras excluded.

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": false,
    "emitDeclarationOnly": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/**/*.test.ts", "src/**/__fixtures__/**"]
}

Excluding tests from the build config is not cosmetic. A test file included in the emit produces dist/index.test.d.ts, which appears in the tarball, references your test framework’s types (a dev dependency consumers do not have), and can pull the framework into the declaration graph — producing the confusing failure where installing your library makes a consumer’s build complain about missing vitest types.

rootDir deserves a mention of its own because its failure mode is so recognisable. If any included file sits outside rootDir, TypeScript silently restructures the output to preserve relative paths, and dist/index.js becomes dist/src/index.js — breaking every path in your exports map at once. The usual trigger is a shared file imported from a sibling directory, and the diagnosis is simply to look at the emitted tree:

tsc -p tsconfig.build.json --emitDeclarationOnly --listEmittedFiles | head
TSFILE: /repo/dist/index.d.ts
TSFILE: /repo/dist/client.d.ts
TSFILE: /repo/dist/types.d.ts

Three files at the top level of dist/, exactly as the exports map expects. A dist/src/ prefix in that listing means something outside rootDir got included, and no amount of adjusting the exports paths is the right fix — the include list is.

Finally, keep skipLibCheck in mind as the trade-off it is. Turning it on skips type-checking of all .d.ts files, including your dependencies’, which speeds up builds substantially and hides genuine incompatibilities between the type packages you depend on. For a library, the compromise most teams settle on is skipLibCheck: true for the everyday development config and a periodic CI job that runs with it off, so the incompatibilities are known about rather than merely deferred. The distinction matters when a consumer reports a type error originating inside one of your dependencies: with skipLibCheck on everywhere, that error is news to you, and the first hour of the investigation goes into reproducing something your own build was configured never to show you. Scheduling that unskipped run weekly rather than per-commit keeps the everyday build fast while ensuring the backlog of hidden incompatibilities never grows past a week’s worth. Treat any new failure there as a dependency problem to report upstream rather than a local one to suppress.


The Configuration Chain a Library Actually Uses

Three configuration files, each with one job, is the arrangement that stays comprehensible as a project grows.

The three-file configuration chain A base config holds shared compiler options with no paths. The development config adds includes for tests and scripts. The build config narrows the includes and turns on declaration emit into the distribution directory. One job per file, one direction of inheritance tsconfig.base.json strictness, target, module no path-bearing options tsconfig.json includes tests + scripts editor and type-check only tsconfig.build.json src only, emit declarations the one that ships Only the right-hand file affects consumers — review changes to it accordingly

Keeping path-bearing options out of the base file is what makes the chain portable: extends resolves some paths relative to the base and others relative to the extending file, and the simplest way to never think about that again is to have no paths in the base at all.


Pages in This Section



TypeScript Configuration & Build Tooling