TypeScript tsconfig.json – The Path Alias Runtime Trap
tsconfig.json path aliases silently pass type-check but crash at runtime without bundler config.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
- 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
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.
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.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.
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.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.
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.tsconfig-paths-webpack-plugin; for Vite, use resolve.alias.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.
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.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.
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.
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.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.
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.The Silent Missing Export: When Path Aliases Pass Type-Check but Fail at Runtime
Module not found: Error: Can't resolve '@utils/dateHelpers'. No error during development because the IDE uses TypeScript's type system, which respects paths.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.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.- Path aliases are a compile-time fiction — you must mirror them in every runtime environment (bundler, ts-node, Jest).
- Never trust
tsc --noEmitalone 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.
tsconfig-paths/register or bundler alias. Also verify baseUrl is set correctly when using paths.Ctrl+Shift+P → TypeScript: Select TypeScript Version → match project version.exclude: ["node_modules"] in a child config that sets exclude. Add it back — the child config replaces, not merges.@types packages. Also verify that skipLibCheck: true is set to avoid transient errors from third-party definitions.Check emitted JS: `npx tsc --showConfig` to see if paths are listedTest alias resolution: `node -e "require('@utils/test')"` after running with `-r tsconfig-paths/register`tsconfig-paths-webpack-plugin and add to Webpack plugins. For Vite, use resolve: { alias: { '@utils': path.resolve(__dirname, 'src/utils') } }Key takeaways
target controls what JavaScript is emitted; lib controls what APIs TypeScript *assumes existpaths are purely a compile-time type-resolution hintinclude/exclude arrays are fully replaced by child configs that set themnode_modules exclusions in every child tsconfig. and tsc --build` give you incremental builds in monorepos, reducing full type-check from minutes to seconds.Common mistakes to avoid
5 patternsRunning `tsc src/index.ts` instead of bare `tsc`
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
tsc compiles cleanly but Node.js throws Error: Cannot find module '@utils/dateHelpers' at runtime.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
exclude: ['dist'], TypeScript suddenly takes much longer to compile and reports hundreds of type errors from inside node_modules.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
tsc --build fails with error TS6306: Referenced project '...' must have setting "composite": true."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
require is not defined in the browser. Or dynamic imports don't work correctly."module": "ESNext" and "moduleResolution": "bundler" (or "node16" if using both). The bundler will handle module resolution and tree-shaking.Interview Questions on This Topic
What is the difference between `target` and `lib` in tsconfig.json, and why might you need to set them independently?
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.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's TypeScript. Mark it forged?
6 min read · try the examples if you haven't