Intermediate 4 min · March 05, 2026

TypeScript tsconfig.json – The Path Alias Runtime Trap

tsconfig.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
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

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.

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

  • 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.
  • target controls what JavaScript is emitted; lib controls what APIs TypeScript assumes exist — they're independent and both must match your deployment environment.
  • 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.
  • 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.
  • 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

  • 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 Questions on This Topic

  • QWhat is the difference between target and lib in tsconfig.json, and why might you need to set them independently?Mid-levelReveal
    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.
  • 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
    Don't turn off the entire strict: true umbrella. It enables 8 separate sub-checks, most of which are valuable (e.g., strictNullChecks prevents null pointer issues). Instead, identify which sub-check is causing the most noise and disable only that one individually. For example, if strictPropertyInitialization is causing grief in a class-heavy codebase, set strictPropertyInitialization: false while keeping the rest active. Over time, work to fix the violations and re-enable the sub-check. This way you maintain most of the safety net without blocking progress.
  • 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
    Path aliases in paths are only used by TypeScript's type-checker to resolve type declarations. The emitted JavaScript still contains the raw alias string (e.g., @utils/dateHelpers). At runtime, Node.js or the bundler doesn't know about this alias. To fix: install tsconfig-paths and register it before running the app (node -r tsconfig-paths/register dist/index.js). For bundlers, add corresponding resolve.alias configuration (Webpack) or resolve.alias (Vite). Alternatively, use the tsconfig-paths-webpack-plugin to sync aliases from tsconfig.json automatically.
  • QExplain how extends works in tsconfig.json. What is merged and what is replaced?SeniorReveal
    extends copies all settings from a base config and then the child config's settings override them. compilerOptions are merged (child options win on conflict, but other options from base remain). However, include, exclude, and files arrays are fully replaced — not merged. So if the base has exclude: ['node_modules'] and the child sets exclude: ['dist'], the child will exclude only dist, not node_modules. This is a common gotcha. Also, references arrays are not inherited at all.
  • QWhat does composite: true do, and why is it required for project references?SeniorReveal
    composite: true tells TypeScript that this project is a building block for other projects. It forces declaration: true (generation of .d.ts files) and enables incremental compilation via .tsbuildinfo files. Without it, a project cannot be referenced by another project because there would be no way for the referencing project to get type declarations without re-checking the referenced source files. It's required by the references mechanism.

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

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