Senior 6 min · March 05, 2026

TypeScript tsconfig.json – The Path Alias Runtime Trap

tsconfig.json path aliases silently pass type-check but crash at runtime without bundler config.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Production
production tested
June 10, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • tsconfig.json is the single source of truth for TypeScript compiler behaviour
  • Key options: target, module, strict, lib, paths, outDir, rootDir
  • Path aliases need a runtime companion (tsconfig-paths or bundler config)
  • include/exclude arrays are replaced — not merged — in child configs
  • Production insight: misconfigured moduleResolution silently breaks bundler builds
  • Biggest mistake: running tsc with file arguments bypasses tsconfig entirely
✦ Definition~90s read
What is TypeScript tsconfig?

tsconfig.json is TypeScript's compiler configuration file — the single source of truth for how tsc transpiles your code. Without it, TypeScript defaults to ES3 output with loose checks, which is useless for modern projects. It defines everything from module resolution strategy (node, node16, bundler) to strictness flags (strict: true enables a cascade of checks like noImplicitAny and strictNullChecks).

Imagine you're a chef opening a restaurant.

Critically, tsconfig.json is not just a settings file; it also determines which files are included in compilation via include, exclude, and files — a detail that trips up teams when files silently fall out of compilation.

Path aliases (paths and baseUrl) solve the '../../../components/Button' hell by letting you write @/components/Button instead. But here's the runtime trap: TypeScript only resolves these aliases during type-checking and compilation. It does not rewrite import paths in the emitted JavaScript.

If you use paths without a bundler (Webpack, Vite, esbuild) or a runtime resolver (tsconfig-paths, tsc-alias), your Node.js server will crash with ERR_MODULE_NOT_FOUND at runtime. This is the single most common footgun in TypeScript projects — developers assume tsc handles path resolution end-to-end, but it doesn't.

For monorepos, project references and composite: true let you split a codebase into independently compilable packages with dependency graphs. Combined with paths, you can alias internal packages (@myorg/shared) without publishing to npm. But again, the runtime trap persists: paths in referenced projects are not automatically inherited — each sub-project needs its own tsconfig with explicit alias mappings, or you use a build orchestrator like Turborepo or Nx to handle the linking.

The ecosystem has converged on using bundlers for runtime resolution and tsconfig purely for type-checking, but many teams still conflate the two roles, leading to brittle builds that work in CI but fail in production.

Plain-English First

Imagine you're a chef opening a restaurant. Before you cook anything, you hand your kitchen staff a rulebook: use metric measurements, never serve raw chicken, always plate desserts last. The tsconfig.json file is that rulebook for the TypeScript compiler. It tells TypeScript exactly how strict to be, which files to look at, and where to put the finished JavaScript. Without it, every cook (compiler invocation) would guess the rules differently.

Path aliases in tsconfig.json let you write clean imports like @/utils/dateHelpers instead of ../../../shared/utils/dateHelpers, but they only work during type-checking. The emitted JavaScript still contains the alias paths, which Node.js cannot resolve, causing ERR_MODULE_NOT_FOUND crashes at runtime. This trap is the most common silent failure in TypeScript projects — type-check passes, but production breaks. You must configure your bundler (Webpack, Vite, esbuild) or use a runtime resolver (tsconfig-paths, tsc-alias) to rewrite those paths in the output.

What tsconfig.json Actually Does (and Why 'tsc' Alone Isn't Enough)

When you run tsc in a TypeScript project, the compiler goes looking for a tsconfig.json in the current directory and walks up the folder tree until it finds one. That file is the contract between you and the compiler.

It does three things. First, it defines the root files — which source files are part of this compilation. Second, it controls compiler behaviour — strictness, output format, target JavaScript version. Third, it sets output destinations — where compiled .js files land, whether declaration files are generated, and whether source maps are emitted.

Why not just use CLI flags? Because flags don't scale. A real project might need 15 or more options. CLI flags can't be code-reviewed meaningfully, can't be shared across scripts, and change between npm scripts without anyone noticing. tsconfig.json makes your build reproducible — the same way a Dockerfile makes your server environment reproducible.

One subtlety: running tsc with explicit filenames on the command line (e.g. tsc src/index.ts) ignores your tsconfig.json entirely. TypeScript only reads the config when you run tsc without file arguments. This surprises a lot of developers.

generate-tsconfig.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
# Step 1: Install TypeScript locally (always prefer local over global)
npm install --save-dev typescript

# Step 2: Let TypeScript generate a starter tsconfig with all options documented
npx tsc --init

# Step 3: Verify the compiler is reading your config (not guessing)
# This prints the resolved config TypeScript will actually use
npx tsc --showConfig

# Step 4: Type-check without emitting any JavaScript files
# Useful in CI to catch errors fast without cluttering the output folder
npx tsc --noEmit
Output
# After --init you'll see:
Created a new tsconfig.json with:
target: es2016
module: commonjs
strict: true
esModuleInterop: true
skipLibCheck: true
forceConsistentCasingInFileNames: true
# After --showConfig you'll see the fully resolved JSON printed to stdout
# including all inherited and default values — great for debugging
Watch Out:
Running tsc src/index.ts directly bypasses tsconfig.json completely. You'll see different errors (or none at all) compared to running tsc alone. Always run bare tsc or tsc --noEmit in CI to honour the project config.
Production Insight
Running tsc with filenames is a silent config bypass — your CI will catch errors that devs never saw locally.
Use tsc --noEmit as your canonical type-check in CI, not tsc with a file list.
The difference between local and CI configs is the most common source of 'works on my machine' bugs.
Key Takeaway
tsc without file arguments reads tsconfig.json.
tsc with filenames is a separate mode that ignores the config entirely.
Always run bare tsc or tsc --noEmit to get the config-respected behaviour.
TypeScript tsconfig.json Path Alias Runtime Trap THECODEFORGE.IO TypeScript tsconfig.json Path Alias Runtime Trap How path aliases in tsconfig.json fail at runtime and how to fix it tsconfig.json Compiler Options Only affect tsc compilation, not runtime Path Aliases Defined Mapped in paths and baseUrl options Compiled Output Aliases not resolved in emitted JS Runtime Module Resolution Node.js cannot resolve aliased paths Fix with Bundler or Module Alias Use tsconfig-paths or bundler plugin ⚠ Path aliases are compile-time only; runtime fails silently Use tsconfig-paths/register or bundler alias resolution THECODEFORGE.IO
thecodeforge.io
TypeScript tsconfig.json Path Alias Runtime Trap
Typescript Tsconfig Explained

The Compiler Options That Actually Matter — strict, target, module and Friends

tsconfig.json has over 100 options, but roughly 8 of them determine 90% of your project's behaviour. Let's walk through them with the context that's always missing from the docs.

strict: true is a single flag that enables eight separate checks: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables. Turn on strict: true and you've turned on all of them at once. Most teams set this and never look back.

target tells TypeScript which JavaScript version to emit. Set target: 'ES2017' and TypeScript will downcompile async/await to Promise chains. Set target: 'ESNext' and it emits modern JS untouched. This is separate from what your runtime supports — so match it to your deployment environment (Node.js version, browser support matrix).

module controls how import/export statements are compiled. commonjs is right for Node.js. ESNext is right for bundlers like Vite or Webpack. Getting this wrong produces runtime errors that type-checking can't catch.

lib tells TypeScript which browser/runtime APIs exist. If you're targeting older browsers but your target is ES2020, you still need to list lib: ['ES2020', 'DOM'] so TypeScript knows fetch and Promise are available.

tsconfig.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
{
  "compilerOptions": {
    // --- LANGUAGE & ENVIRONMENT ---

    // Emit JavaScript compatible with Node 18 / modern browsers.
    // TypeScript will downcompile features not available in ES2022.
    "target": "ES2022",

    // Tell TypeScript which global APIs exist in this environment.
    // DOM gives you fetch, document, etc. ES2022 gives you Array.at(), etc.
    "lib": ["ES2022", "DOM"],

    // How import/export is compiled. Use 'commonjs' for Node, 'ESNext' for bundlers.
    "module": "commonjs",

    // Where to look when resolving modules.
    // 'node16' is the safest choice for modern Node.js projects.
    "moduleResolution": "node16",

    // --- STRICTNESS (always enable this in new projects) ---

    // Umbrella flag that enables 8 separate strict checks in one line.
    // Turning this off is how 'any' type sneaks back into your codebase.
    "strict": true,

    // Catch uses of variables that may be undefined after a loop or condition.
    // Saves you from subtle bugs TypeScript's strict mode doesn't catch by default.
    "noUncheckedIndexedAccess": true,

    // Error if a local variable is declared but never read.
    // Forces clean code — no 'import React' leftover cruft.
    "noUnusedLocals": true,

    // Error if a function parameter is declared but never used.
    // Prefix with _ (e.g. _unusedParam) to intentionally skip this check.
    "noUnusedParameters": true,

    // --- OUTPUT ---

    // Where compiled .js files land. Keep them out of your src directory.
    "outDir": "./dist",

    // The root of your source files — helps TypeScript mirror the folder structure.
    "rootDir": "./src",

    // Generate .d.ts declaration files so other TypeScript projects can consume this.
    "declaration": true,

    // Generate .js.map files so debuggers show original .ts source, not compiled JS.
    "sourceMap": true,

    // --- INTEROP ---

    // Allows: import express from 'express' instead of: import * as express from 'express'
    // Required for most npm packages that use CommonJS default exports.
    "esModuleInterop": true,

    // Skip type-checking of .d.ts files inside node_modules.
    // Dramatically speeds up compilation and avoids third-party declaration bugs.
    "skipLibCheck": true,

    // Throw an error if you import './MyComponent' but the file is './mycomponent'.
    // Prevents bugs on case-sensitive Linux filesystems when devs use macOS.
    "forceConsistentCasingInFileNames": true
  },

  // Include everything inside src. TypeScript walks subdirectories automatically.
  "include": ["src/**/*"],

  // Exclude compiled output and dependencies — never type-check these.
  "exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
Output
# Run: npx tsc --noEmit
# With a well-configured tsconfig, a clean project produces no output — silence is success.
# If there are errors, you'll see:
#
# src/api/userService.ts:42:5 - error TS2322: Type 'string | undefined' is
# not assignable to type 'string'.
#
# 42 const userId: string = user.id; // noUncheckedIndexedAccess caught this!
# ~~~~~~
#
# Found 1 error.
Pro Tip:
Enable noUncheckedIndexedAccess even though it's not part of strict: true. It catches one of the most common runtime bugs in TypeScript: accessing array[0] without checking it exists. The compiler will type it as T | undefined instead of T, forcing you to handle the empty case.
Production Insight
noUncheckedIndexedAccess is the single most impactful non-strict flag for preventing undefined access.
In a production outage, a missing array bounds check caused a null pointer that took 3 hours to trace.
Rule: always enable this flag — it pays for itself in one debugging session.
Key Takeaway
strict: true enables 8 sub-checks in one flag.
noUncheckedIndexedAccess is not part of strict but catches runtime undefined errors.
target controls emitted syntax, lib controls assumed APIs — they're independent.

Path Aliases and Project References — Escaping the '../../../' Nightmare

Once your project grows past about 20 files, relative imports become painful. You end up with lines like import { formatDate } from '../../../shared/utils/dateHelpers'. Change the file's location and everything breaks. Path aliases fix this.

Path aliases let you write import { formatDate } from '@utils/dateHelpers' from anywhere in the project. TypeScript resolves this at compile time using the paths option in tsconfig. But here's the catch everyone hits: TypeScript only handles the type resolution. The actual runtime resolution is still handled by Node.js or your bundler, so you need a companion tool (like tsconfig-paths for Node or your bundler's alias config for Webpack/Vite) to make it work at runtime too.

Project references solve a different problem: monorepos. If you have a shared packages/utils library and two apps (packages/api and packages/web) that both depend on it, you want each package to have its own tsconfig and to build independently. Project references let TypeScript understand the dependency graph between them, so it builds utils first and uses its compiled output when building api and web.

This also unlocks tsc --build (or tsc -b), which only recompiles packages whose source has actually changed — a massive speed win in large monorepos.

tsconfig.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ─── ROOT tsconfig.json (project with path aliases) ───────────────────────
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,

    // baseUrl is required when using paths.
    // It anchors the path alias resolution to this directory.
    "baseUrl": ".",

    // Map the @alias/* pattern to a real folder path.
    // The array allows fallback paths (TypeScript tries them in order).
    "paths": {
      "@utils/*": ["src/shared/utils/*"],
      "@models/*": ["src/domain/models/*"],
      "@config/*": ["src/config/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Output
// After configuring paths, this import now works from ANY file in the project:
// import { formatCurrency } from '@utils/currencyHelpers';
//
// Without the runtime companion, Node.js will throw:
// Error: Cannot find module '@utils/currencyHelpers'
//
// Fix: install tsconfig-paths and run Node with:
// node -r tsconfig-paths/register dist/index.js
//
// OR, if using Vite, add to vite.config.ts:
// resolve: { alias: { '@utils': path.resolve(__dirname, 'src/shared/utils') } }
Interview Gold:
Interviewers love asking 'why don't path aliases just work automatically at runtime?' The answer: TypeScript is a compile-time tool. It erases all types and emits plain JavaScript. The paths config teaches TypeScript's type system where to find types — but the emitted JS still has the bare @utils/... string, and Node.js has never heard of that alias. You always need a runtime counterpart.
Production Insight
A common failure: tsc passes, dev server runs fine with ts-node, but production build crashes with 'Cannot find module'.
The runtime counterpart is mandatory. For Webpack, use tsconfig-paths-webpack-plugin; for Vite, use resolve.alias.
Without it, path aliases are a local development luxury that breaks in production.
Key Takeaway
Path aliases are compile-time only — always configure runtime resolution.
Use project references for monorepos to get incremental builds.
tsc --build only rebuilds changed packages, saving minutes in large repos.

Extending Configs and Environment-Specific Overrides — DRY tsconfig in the Real World

Most real projects need more than one tsconfig. You might want one for your main build, one for tests (which need different globals and can be more relaxed), and one for a strict CI check. Duplicating options across three files is a maintenance trap.

The extends key solves this. You define a base config with all shared options, then each environment-specific config extends it and adds only what's different. TypeScript deep-merges the two files, with the child config winning on any clash.

The community maintains @tsconfig/recommended and environment-specific presets like @tsconfig/node20 and @tsconfig/strictest on npm. These are great starting points — install one, extend it, and only add project-specific overrides. You get battle-tested defaults without memorising every flag.

A critical detail about extends and paths: include, exclude, and files arrays are not merged — they are entirely replaced by the child config. Only compilerOptions are merged. So if your base tsconfig has exclude: ['node_modules'] and your child config sets exclude: ['dist'], your child is now including node_modules — because it overwrote, not extended, the array. Always re-state exclusions in child configs.

tsconfig.base.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// ─── tsconfig.base.json ───────────────────────────────────────────────────
// Shared foundation. All project configs extend this.
// Never used directly — only as a base.
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node16",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true
  }
}

// ─── tsconfig.json (production build) ─────────────────────────────────────
// Extends base and adds output configuration.
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    // In production, unused variables are a hard error.
    "noUnusedLocals": true,
    "noUnusedParameters": true
  },
  "include": ["src/**/*"],
  // IMPORTANT: Re-declare exclude — it does NOT inherit from base.
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

// ─── tsconfig.test.json (Jest / Vitest test runs) ─────────────────────────
// Extends base but relaxes rules that make test files painful.
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    // Tests don't emit — the test runner handles execution.
    "noEmit": true,
    // Tests legitimately have unused params in mock callbacks — relax this.
    "noUnusedParameters": false,
    // Add Jest's global types (describe, it, expect) without importing them.
    "types": ["jest", "node"]
  },
  // Include both source and test files so TypeScript understands imports in tests.
  "include": ["src/**/*", "tests/**/*"],
  "exclude": ["node_modules", "dist"]
}

// ─── Use in package.json scripts ──────────────────────────────────────────
// {
//   "scripts": {
//     "build":      "tsc -p tsconfig.json",
//     "type-check": "tsc -p tsconfig.json --noEmit",
//     "test":       "jest --project tsconfig.test.json"
//   }
// }
Output
# Production build:
$ npm run build
# TypeScript compiles src/ to dist/ using tsconfig.json
# dist/ contains .js, .d.ts, and .js.map files
# Type-check without emitting (great for CI):
$ npm run type-check
# Exit code 0 = clean. Exit code 1 = errors printed to stderr.
# If you accidentally use an unused variable in src/:
# error TS6133: 'temporaryBuffer' is declared but its value is never read.
Watch Out:
include, exclude and files arrays are REPLACED by the child config, not merged. If your base config excludes node_modules and your child config sets a new exclude array without repeating node_modules, TypeScript will start type-checking your entire node_modules folder. Always redeclare your exclusions in every child config that sets exclude.
Production Insight
A real incident: a team added a tsconfig.test.json that set exclude: ["dist"] but forgot node_modules. CI job jumped from 30 seconds to 8 minutes because TypeScript traversed all of node_modules.
Always redeclare node_modules in every child config's exclude array.
If you see unexpected slowdown, check the resolved exclude with --showConfig.
Key Takeaway
include, exclude, files arrays are replaced — not merged — in child configs.
Use @tsconfig presets to avoid manually maintaining common options.
Always redeclare node_modules in any child config that sets exclude.

Project References and Composite Builds: Scaling TypeScript Across a Monorepo

When your repository grows beyond 50k lines, full tsc runs start to hurt. Even with skipLibCheck, waiting 40 seconds for a type-check after changing one file kills developer flow. Project references solve this by splitting your code into independently compilable units with explicit dependency graphs.

composite: true is the flag that makes a project reference-able. It tells TypeScript to generate .d.ts declaration files and a tsconfig.tsbuildinfo file for incremental builds. Without composite, a project cannot be referenced by another.

references in the root tsconfig lists the sub-projects. When you run tsc --build, TypeScript uses the declared dependency order to build them in the correct sequence. It also uses the .tsbuildinfo files to skip unchanged projects — a huge speed gain.

The catch: referenced projects must have composite: true, and the root project's outDir must be distinct from each sub-project's output. If they overlap, TypeScript will overwrite files and you'll get bizarre import errors.

Also note: tsc --build is strict about the rootDir and include patterns. Each referenced project must have its own rootDir that doesn't overlap with others. You can't just include everything from a shared src folder.

tsconfig.json (root of monorepo)JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ─── Root tsconfig.json ───────────────────────────────────────────────────
// This file acts as the orchestrator. It doesn't compile anything itself.
// It references sub-projects and tells TypeScript how to build them.
{
  "compilerOptions": {
    // composite is not required at root if it's not a library itself
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "references": [
    { "path": "./packages/utils" },
    { "path": "./packages/api" },
    { "path": "./packages/web" }
  ]
}

// ─── packages/utils/tsconfig.json ─────────────────────────────────────────
{
  "compilerOptions": {
    "composite": true,  // Required to be reference-able
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,  // mandatory for composite
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

// ─── packages/api/tsconfig.json ───────────────────────────────────────────
{
  "compilerOptions": {
    "composite": true,
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "sourceMap": true
  },
  "references": [
    // API depends on utils
    { "path": "./../utils" }
  ],
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

// ─── Build command ───────────────────────────────────────────────────────-
// $ npx tsc --build packages/api/tsconfig.json
// This builds utils first (if needed), then api.
// --dry shows what would be built without actually building.
// --clean removes all build artifacts.
Output
# First full build (run from repo root):
$ npx tsc --build packages/api/tsconfig.json
# Output (if all clean):
# packages/utils: building... (because declarations needed)
# packages/api: building...
# Done in 42s
# Second build (no changes):
$ npx tsc --build packages/api/tsconfig.json
# packages/utils: up-to-date
# packages/api: up-to-date
# Done in 0.8s (uses .tsbuildinfo)
# Third build (change one file in api):
$ npx tsc --build packages/api/tsconfig.json
# packages/utils: up-to-date
# packages/api: building... (because source changed)
# Done in 5.2s
Performance Insight:
With project references, a monorepo with 200k lines can achieve incremental builds under 2 seconds. Without them, even with skipLibCheck, a full type-check takes 40+ seconds. The .tsbuildinfo file is the key — it tracks timestamps and file hashes so unchanged projects are skipped entirely.
Production Insight
The most common project reference mistake: overlapping outDirs or rootDirs. If two projects write to the same dist folder, TypeScript's incremental cache gets corrupted, leading to hard-to-debug 'duplicate identifier' errors.
Always ensure each referenced project has a unique outDir and rootDir.
Use tsc --build --dry to validate the dependency graph before the first real build.
Key Takeaway
composite: true is mandatory for any project intended to be referenced.
tsc --build uses .tsbuildinfo for incremental builds — drastically faster than plain tsc.
Overlapping outDirs between references causes silent corruption — always use unique output directories.

The `files`, `include`, and `exclude` Trio — Stop Compiling Everything in Sight

Most devs let tsc scan the whole project. That’s lazy. Your tsconfig.json’s files, include, and exclude fields exist to tell TypeScript exactly what to compile — and more importantly, what to ignore. files is for single-file entry points (rare). include is your workhorse: glob patterns like src/*/.ts. exclude kills noise: node_modules, dist, test fixtures. Without these, TypeScript loads every .ts file in your repo, including dead code and generated output. That slows down editor intellisense and bloats compilation times in CI. The real pro move: start with a tight include and only widen it when you must. Your tsconfig.json isn’t a suggestion — it’s a contract with the compiler.

tsconfig.jsonJSON
1
2
3
4
5
{
  "compilerOptions": { ... },
  "include": ["src/**/*.ts", "src/**/*.tsx"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/__mocks__/*"]
}
Output
// Running `tsc --listFiles` will show only ~200 files instead of 2000+
Production Trap:
If you exclude dist but keep src/*/ as include, TypeScript might still pick up stale .d.ts files if your outDir overlaps. Always make exclude paths absolute or relative from root — and never rely on exclude to hide files from files entries.
Key Takeaway
A tight include/exclude strategy is the single cheapest performance optimization you’ll make today.

The `outDir`/`rootDir` Symmetry — Why Your Build Output Looks Like a Mess

Ever run tsc and get a mirror of your src/ folder inside dist/ but with a nested src/ subfolder? That’s rootDir missing. rootDir tells TypeScript the root of your source tree. outDir is where compiled output lands. Without setting rootDir explicitly, TypeScript infers the longest common path of all input files — and that often points to your project root, not src/. The fix: set rootDir: "./src" and outDir: "./dist". Now src/app.ts compiles to dist/app.js, not dist/src/app.js. This matters for runtime imports, debugging source maps, and Docker layer caching. Don’t guess. Set both. Explicitly.

tsconfig.jsonJSON
1
2
3
4
5
6
7
8
9
10
{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}
Output
# Before: dist/src/app.js After: dist/app.js
Pro Insight:
If you use project references, each sub-project must have its own rootDir/outDir pair. Mismatch them in a monorepo and your composite builds will silently fail or produce broken d.ts paths. I’ve debugged that at 2 AM. Don’t be me.
Key Takeaway
Always pair rootDir with outDir. If you can write dist/src/anything, you’ve already lost.
● Production incidentPOST-MORTEMseverity: high

The Silent Missing Export: When Path Aliases Pass Type-Check but Fail at Runtime

Symptom
tsc --noEmit passes cleanly on every developer's machine and in CI. But after the application is bundled with Webpack and deployed to staging, every import using path aliases throws Module not found: Error: Can't resolve '@utils/dateHelpers'. No error during development because the IDE uses TypeScript's type system, which respects paths.
Assumption
The team assumed that because TypeScript resolved aliases during type-checking and during local development (via ts-node), the same would hold in the production bundle.
Root cause
The paths option in tsconfig.json only teaches the type-checker where to find types. The emitted JavaScript still contains the raw alias string (e.g., @utils/dateHelpers). Node.js and Webpack have never heard of @utils. The team forgot to configure resolve.alias in their Webpack configuration.
Fix
Add the corresponding aliases in Webpack's resolve.alias (or Vite's resolve.alias if using Vite). Alternatively, use the tsconfig-paths-webpack-plugin to automatically sync aliases from tsconfig.json to Webpack. After deployment, run a smoke test that exercises an endpoint using an alias-bearing import.
Key lesson
  • Path aliases are a compile-time fiction — you must mirror them in every runtime environment (bundler, ts-node, Jest).
  • Never trust tsc --noEmit alone to validate resolvable imports. Always run a full build and verify the output bundle.
  • Add an integration test that imports a module via path alias and asserts the resolved file is correct.
Production debug guideSymptoms, root causes, and fixes for the most common pitfalls4 entries
Symptom · 01
tsc compiles without errors, but Node.js throws 'Cannot find module' for a local file
Fix
Check if you're using path aliases. If so, configure tsconfig-paths/register or bundler alias. Also verify baseUrl is set correctly when using paths.
Symptom · 02
Your IDE shows errors, but running tsc --noEmit shows none
Fix
Ensure your IDE is using the same tsconfig.json that tsc reads. In VSCode, check the TypeScript version and the tsconfig used: Ctrl+Shift+PTypeScript: Select TypeScript Version → match project version.
Symptom · 03
TypeScript compilation suddenly takes much longer and reports errors from node_modules
Fix
You probably forgot to re-declare exclude: ["node_modules"] in a child config that sets exclude. Add it back — the child config replaces, not merges.
Symptom · 04
Build fails on CI but works locally
Fix
Check whether the CI environment has a different Node.js version or missing @types packages. Also verify that skipLibCheck: true is set to avoid transient errors from third-party definitions.
★ tsconfig.json Quick Debug Cheat SheetFive-minute fixes for the most common tsconfig issues
Path alias `@utils/...` not resolved at runtime after bundling
Immediate action
Add matching alias to your bundler config (Webpack resolve.alias / Vite resolve.alias / Next.js via tsconfig path plugin)
Commands
Check emitted JS: `npx tsc --showConfig` to see if paths are listed
Test alias resolution: `node -e "require('@utils/test')"` after running with `-r tsconfig-paths/register`
Fix now
Install tsconfig-paths-webpack-plugin and add to Webpack plugins. For Vite, use resolve: { alias: { '@utils': path.resolve(__dirname, 'src/utils') } }
tsc suddenly reports hundreds of errors from node_modules+
Immediate action
Check if your tsconfig has an `exclude` array — if it does, it probably replaced the parent's exclusion of node_modules
Commands
Run `npx tsc --showConfig | grep exclude` to see current exclusions
Check parent tsconfig: look at the `extends` chain for initial `exclude`
Fix now
Add "exclude": ["node_modules", "dist"] explicitly in every child tsconfig that sets its own exclude
IDE shows errors that tsc --noEmit doesn't reproduce+
Immediate action
Force VSCode to reload its TypeScript service
Commands
In VSCode: `Ctrl+Shift+P` → `TypeScript: Restart TS server`
Check which tsconfig the IDE is using: `Ctrl+Shift+P` → `TypeScript: Select TypeScript Project`
Fix now
If mismatch persists, add "typescript.tsdk": "./node_modules/typescript/lib" to VSCode settings.json
Module 'crypto' cannot be found after target is set to ES2021+
Immediate action
Check if `lib` includes both `ES2021` and `Node` (or appropriate environment)
Commands
Run `npx tsc --showConfig | grep lib`
Verify Node.js version matches: `node -e "console.log(process.versions.node)"`
Fix now
Add "types": ["node"] to compilerOptions if using Node.js APIs, and ensure @types/node is installed
Key tsconfig Options at a Glance
OptionWhat It ControlsRecommended ValueWhy You'd Change It
strictEnables 8 strict sub-flags at oncetrueOnly disable specific sub-flags, never the whole umbrella
targetJavaScript version emitted to diskES2022 for Node 18+Lower for older browser support (ES5, ES2015)
moduleHow imports/exports are compiledcommonjs for Node; ESNext for bundlersESNext needed for Vite, Rollup, native ESM
moduleResolutionHow TypeScript finds imported filesnode16 for modern Node.jsbundler for Vite/Webpack projects
noUncheckedIndexedAccessArray[i] typed as T | undefinedtrue (not in strict by default!)Enable to catch 'undefined is not a function' runtime errors
skipLibCheckSkips checking .d.ts in node_modulestrueSet false only when debugging third-party type bugs
declarationGenerates .d.ts files alongside .jstrue for libraries, false for appsApps don't export types; libraries must
sourceMapMaps compiled JS back to original TStrue in dev/library, false to save bytes in prod appsAlways true if you want readable stack traces
compositeMakes project referenceable by otherstrue for library packages in monorepoRequired if using project references

Key takeaways

1
`strict
true` is a single flag that activates 8 separate checks — enable it on every new project and never disable the umbrella; override individual sub-flags if needed.
2
target controls what JavaScript is emitted; lib controls what APIs TypeScript *assumes exist
they're independent and both must match your deployment environment.
3
Path aliases defined in paths are purely a compile-time type-resolution hint
you always need a runtime companion (tsconfig-paths, bundler alias config) to make them work when the code actually runs.
4
include/exclude arrays are fully replaced by child configs that set them
they don't merge with the parent, so always redeclare node_modules exclusions in every child tsconfig.
5
Project references with `composite
true and tsc --build` give you incremental builds in monorepos, reducing full type-check from minutes to seconds.

Common mistakes to avoid

5 patterns
×

Running `tsc src/index.ts` instead of bare `tsc`

Symptom
Errors you fixed in tsconfig.json keep appearing, or disappear when they shouldn't. TypeScript completely ignores tsconfig.json when you pass filenames directly on the command line.
Fix
Always run tsc or tsc --noEmit (or tsc -p tsconfig.json) without filenames to honour your project config.
×

Expecting path aliases to work at runtime without a companion tool

Symptom
tsc compiles cleanly but Node.js throws Error: Cannot find module '@utils/dateHelpers' at runtime.
Fix
TypeScript's paths only teaches the type-checker. The emitted JavaScript still contains the raw alias string. Add tsconfig-paths for Node.js (node -r tsconfig-paths/register dist/index.js) or configure matching aliases in your bundler (Vite's resolve.alias, Webpack's resolve.alias).
×

Setting `exclude` in a child config and forgetting it replaces, not extends

Symptom
After adding a child tsconfig that sets exclude: ['dist'], TypeScript suddenly takes much longer to compile and reports hundreds of type errors from inside node_modules.
Fix
Always redeclare node_modules (and any other needed exclusions) in every child tsconfig that defines an exclude array: "exclude": ["node_modules", "dist"].
×

Not setting `composite: true` for referenced projects in a monorepo

Symptom
Running tsc --build fails with error TS6306: Referenced project '...' must have setting "composite": true.
Fix
Add "composite": true to every sub-project that is listed in a references array. Also ensure declaration: true and a proper outDir are set.
×

Using `module: commonjs` with a bundler that expects ESM output

Symptom
The build succeeds but the bundled application fails with require is not defined in the browser. Or dynamic imports don't work correctly.
Fix
When using a bundler like Vite, Webpack, or Rollup, set "module": "ESNext" and "moduleResolution": "bundler" (or "node16" if using both). The bundler will handle module resolution and tree-shaking.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between `target` and `lib` in tsconfig.json, and ...
Q02SENIOR
If a colleague says 'I enabled strict mode but I'm getting too many erro...
Q03SENIOR
You configure path aliases in tsconfig.json, TypeScript compiles without...
Q04SENIOR
Explain how `extends` works in tsconfig.json. What is merged and what is...
Q05SENIOR
What does `composite: true` do, and why is it required for project refer...
Q01 of 05SENIOR

What is the difference between `target` and `lib` in tsconfig.json, and why might you need to set them independently?

ANSWER
target controls the JavaScript syntax level TypeScript emits (e.g., ES2020 emits async/await as-is; ES5 downcompiles it). lib tells TypeScript which global APIs exist in the runtime environment (e.g., DOM for browser APIs like fetch, ES2020.Promise for modern Promise features). They are independent because you might target a modern JavaScript syntax (ES2020) but run in a runtime that doesn't have certain APIs (e.g., Node 14 lacks Array.flat), so you'd set lib: ['ES2020'] without DOM if not in browser. Conversely, you might target ES5 for browser compatibility but still need lib: ['ES2015'] for Promise types if you're polyfilling.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Do I need a tsconfig.json if I'm using Vite or Next.js?
02
What's the difference between `tsconfig.json` `files`, `include`, and `exclude`?
03
Why does `tsc --noEmit` exist? Isn't the whole point of TypeScript to produce JavaScript?
04
What happens if I set both `target` and `module` to ESNext but use a runtime that doesn't support ESM?
05
Should I commit `tsconfig.tsbuildinfo` to version control?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.

Follow
Verified
production tested
June 10, 2026
last updated
1,554
articles · all by Naren
🔥

That's TypeScript. Mark it forged?

6 min read · try the examples if you haven't

Previous
TypeScript Utility Types Deep Dive: Real Examples from Production
9 / 15 · TypeScript
Next
JavaScript vs TypeScript: Key Differences