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.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
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).
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.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
# 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
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.
{
"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"]
}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.
// ─── 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"] }
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.
// ─── 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" // } // }
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.
// ─── 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.
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.
{
"compilerOptions": { ... },
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/__mocks__/*"]
}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.
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"]
}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') } }Run `npx tsc --showConfig | grep exclude` to see current exclusionsCheck parent tsconfig: look at the `extends` chain for initial `exclude`"exclude": ["node_modules", "dist"] explicitly in every child tsconfig that sets its own excludeIn VSCode: `Ctrl+Shift+P` → `TypeScript: Restart TS server`Check which tsconfig the IDE is using: `Ctrl+Shift+P` → `TypeScript: Select TypeScript Project`"typescript.tsdk": "./node_modules/typescript/lib" to VSCode settings.jsonRun `npx tsc --showConfig | grep lib`Verify Node.js version matches: `node -e "console.log(process.versions.node)"`"types": ["node"] to compilerOptions if using Node.js APIs, and ensure @types/node is installed| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| generate-tsconfig.sh | npm install --save-dev typescript | What tsconfig.json Actually Does (and Why 'tsc' Alone Isn't |
| tsconfig.json | { | The Compiler Options That Actually Matter |
| tsconfig.base.json | { | Extending Configs and Environment-Specific Overrides |
| tsconfig.json (root of monorepo) | { | Project References and Composite Builds |
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.If 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?
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.You 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?
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.Explain how `extends` works in tsconfig.json. What is merged and what is replaced?
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.What does `composite: true` do, and why is it required for project references?
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
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.
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.
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.
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.
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.
20+ years shipping production JavaScript and front-end systems at scale. Written from production experience, not tutorials.
That's TypeScript. Mark it forged?
4 min read · try the examples if you haven't