Home JavaScript TypeScript tsconfig.json Explained — Options, Paths and Best Practices

TypeScript tsconfig.json Explained — Options, Paths and Best Practices

In Plain English 🔥
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.
⚡ Quick Answer
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.

Every serious TypeScript project lives and dies by its tsconfig.json. It's the single file that transforms TypeScript from 'a fancy JavaScript linter' into a genuine type-safety system you can trust at scale. Misunderstand it and you'll waste hours debugging phantom type errors, shipping broken JavaScript, or wondering why your IDE autocomplete stopped working on a Monday morning.

The problem tsconfig.json solves is chaos. TypeScript's compiler has dozens of behaviour knobs — strictness, output format, module resolution, JSX handling — and without a central config file you'd have to pass every one as a CLI flag every single time you build. Worse, teammates would use different flags and your project would behave differently on every machine. tsconfig.json locks those decisions in one place, under version control, so the whole team and your CI pipeline agree on exactly what 'valid TypeScript' means for your project.

By the end of this article you'll be able to write a tsconfig.json from scratch and explain every line you've written. You'll know which strict flags actually matter and why, how to handle monorepo setups with project references, how to configure path aliases so you stop writing '../../../utils', and the three most dangerous misconfigurations that quietly break production builds.

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.sh · BASH
12345678910111213
# 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.

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.json · JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
{
  "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.

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.json · JSON
12345678910111213141516171819202122232425262728
// ─── 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.

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 · JSON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// ─── 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`.
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

🎯 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

⚠ Common Mistakes to Avoid

  • Mistake 1: 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.
  • Mistake 2: 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).
  • Mistake 3: Setting exclude in a child config and forgetting it replaces, not extends, the base config's exclude — 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"].

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?
  • 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?
  • 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?

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.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousTypeScript Utility TypesNext →TypeScript vs JavaScript
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged