TypeScript tsconfig.json – The Path Alias Runtime Trap
tsconfig.
- 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
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.
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.
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.
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.
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.
| Option | What It Controls | Recommended Value | Why You'd Change It |
|---|---|---|---|
| strict | Enables 8 strict sub-flags at once | true | Only disable specific sub-flags, never the whole umbrella |
| target | JavaScript version emitted to disk | ES2022 for Node 18+ | Lower for older browser support (ES5, ES2015) |
| module | How imports/exports are compiled | commonjs for Node; ESNext for bundlers | ESNext needed for Vite, Rollup, native ESM |
| moduleResolution | How TypeScript finds imported files | node16 for modern Node.js | bundler for Vite/Webpack projects |
| noUncheckedIndexedAccess | Array[i] typed as T | undefined | true (not in strict by default!) | Enable to catch 'undefined is not a function' runtime errors |
| skipLibCheck | Skips checking .d.ts in node_modules | true | Set false only when debugging third-party type bugs |
| declaration | Generates .d.ts files alongside .js | true for libraries, false for apps | Apps don't export types; libraries must |
| sourceMap | Maps compiled JS back to original TS | true in dev/library, false to save bytes in prod apps | Always true if you want readable stack traces |
| composite | Makes project referenceable by others | true for library packages in monorepo | Required if using project references |
Key Takeaways
strict: trueis 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.targetcontrols what JavaScript is emitted;libcontrols what APIs TypeScript assumes exist — they're independent and both must match your deployment environment.- Path aliases defined in
pathsare 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. include/excludearrays are fully replaced by child configs that set them — they don't merge with the parent, so always redeclarenode_modulesexclusions in every child tsconfig.- Project references with
composite: trueandtsc --buildgive you incremental builds in monorepos, reducing full type-check from minutes to seconds.
Common Mistakes to Avoid
- 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 runtscortsc --noEmit(ortsc -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'spathsonly teaches the type-checker. The emitted JavaScript still contains the raw alias string. Addtsconfig-pathsfor Node.js (node -r tsconfig-paths/register dist/index.js) or configure matching aliases in your bundler (Vite'sresolve.alias, Webpack'sresolve.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 redeclarenode_modules(and any other needed exclusions) in every child tsconfig that defines anexcludearray:"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": trueto every sub-project that is listed in areferencesarray. Also ensuredeclaration: trueand a properoutDirare 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 Questions on This Topic
- QWhat is the difference between
targetandlibin tsconfig.json, and why might you need to set them independently?Mid-levelReveal - QIf a colleague says 'I enabled strict mode but I'm getting too many errors — I'll just turn it off', what would you suggest instead, and why?SeniorReveal
- QYou configure path aliases in tsconfig.json, TypeScript compiles without errors, but the app crashes at runtime with 'Cannot find module'. What's happening and how do you fix it?Mid-levelReveal
- QExplain how
extendsworks in tsconfig.json. What is merged and what is replaced?SeniorReveal - QWhat does
composite: truedo, and why is it required for project references?SeniorReveal
Frequently Asked Questions
Do I need a tsconfig.json if I'm using Vite or Next.js?
Yes — but those frameworks ship a default tsconfig.json for you. You should still understand it and customise it for your project. Vite uses moduleResolution: 'bundler' and Next.js adds jsx: 'preserve' for its own JSX transform. Accepting the defaults blindly means you can miss stricter checks like noUncheckedIndexedAccess that catch real bugs.
What's the difference between `tsconfig.json` `files`, `include`, and `exclude`?
files is an explicit whitelist of specific file paths — TypeScript compiles exactly those files and nothing else. include accepts glob patterns and tells TypeScript which files to add to the compilation. exclude removes matches from what include found. If you set none of these, TypeScript defaults to including all .ts files in the project directory tree except node_modules. Most projects only need include and exclude.
Why does `tsc --noEmit` exist? Isn't the whole point of TypeScript to produce JavaScript?
In many modern setups (Vite, ts-node, Jest with ts-jest, SWC), something other than tsc does the actual JavaScript emission — often something much faster that skips type-checking entirely. In those projects tsc --noEmit is used purely as a type-checker, typically in CI, to catch type errors without duplicating the build output. It separates the 'check correctness' step from the 'emit fast JS' step.
What happens if I set both `target` and `module` to ESNext but use a runtime that doesn't support ESM?
You'll get runtime errors like SyntaxError: Cannot use import statement outside a module in Node.js if your entry point is not in an ESM package or not loaded as ES module. TypeScript will emit import/export statements natively. The runtime must support the module system you emit. For Node.js, ensure your package.json has "type": "module" or use .mjs extension.
Should I commit `tsconfig.tsbuildinfo` to version control?
No — this file is a build cache specific to your machine. It contains file timestamps and dependency graphs. Commit it and you'll cause unnecessary merge conflicts and invalidate the cache for other developers. Add *.tsbuildinfo to .gitignore.
That's TypeScript. Mark it forged?
4 min read · try the examples if you haven't