tsconfig.json Explained

September 29, 20268 min read
Requirements:
FunctionsObjectsGenerics

A tsconfig.json file marks the root of a TypeScript project and tells the compiler two things: which files belong to it, and how to check and emit them. Every tsc run, every editor hint, and every "cannot find module" error you have ever seen traces back to it.

The official reference lists over a hundred options. You need about ten. This page covers the ones that change something you will actually notice, a starter config you can copy, and the handful of settings that quietly cost people an afternoon.

What tsconfig.json Actually Does

Run tsc in a directory with a tsconfig.json and the compiler stops looking at your command line arguments entirely. The file wins. That is the first thing to know: a config file and CLI flags are not additive, and passing a file path to tsc makes it ignore the config file completely.

The second thing: the compiler resolves the config upward from the current directory, so a nested package with no config of its own inherits nothing. Each project needs its own file, or an extends chain pointing at a shared one.

A tsconfig.json That Works

Here is a config for a modern bundled app — Next.js, Vite, anything where a bundler handles the emit and TypeScript only type-checks.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "isolatedModules": true
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}

For a library or a Node service that TypeScript itself compiles, swap the last three lines of compilerOptions for an emit setup:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "strict": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src",
    "sourceMap": true
  },
  "include": ["src"]
}

Those two cover most projects. The rest of this page explains why each line is there.

strict Is the One Setting That Matters

"strict": true is not a single check. It is an umbrella that turns on eight of them, including strictNullChecks, noImplicitAny, and strictFunctionTypes. Turning it off does not make TypeScript lenient — it makes TypeScript lie to you.

strictNullChecks is the one that carries the weight. Without it, null and undefined are assignable to every type, so an optional property reads as if it were always there.

type ProjectConfig = { rootDir?: string }
 
function resolveRoot(config: ProjectConfig): string {
  // With strictNullChecks off, `config.rootDir` is typed `string` right here
  // and this function compiles without the fallback. With it on, TypeScript
  // makes you handle the `undefined` case.
  return config.rootDir ?? 'src'
}

That is the difference between a type that documents your data and a type that checks it. See optional properties for how the ? modifier behaves once strict is on.

If you are adding TypeScript to an existing JavaScript codebase, turn strict on from day one and use // @ts-expect-error on the files that fight back. Retrofitting it later is much worse than the initial pain.

noUncheckedIndexedAccess

This one is not in strict and it should be. It makes array and index-signature lookups return T | undefined, which is what they actually do at runtime.

const compilerFlags: string[] = ['--strict', '--noEmit']
 
// Without noUncheckedIndexedAccess: string
// With it: string | undefined — because index 0 might not exist
const firstFlag = compilerFlags[0]

It is noisy in code that indexes a lot. It is also the single most effective way to find real crashes in code that trusts array access.

target and lib

target sets the JavaScript version TypeScript emits. lib sets which built-in APIs the compiler believes exist. They default together — set target: "ES2022" and you get the ES2022 library types for free — so you only need lib explicitly when you want something extra, like DOM for browser code or a newer library than your emit target.

target is not a purely cosmetic setting. It changes class semantics:

class BuildTimer {
  startedAt = Date.now()
  label = 'build'
}

Below ES2022, those fields are emitted as assignments in the constructor. At ES2022 and above they become native class fields defined with Object.defineProperty, which behave differently around inheritance and accessors. If a target bump breaks a subclass, this is usually why — see classes in TypeScript for the details.

Pick the lowest target your runtime actually requires, and no lower. ES2022 is safe everywhere in 2026 outside of legacy browser support.

module and moduleResolution

These two decide what your imports mean, and they are the most common source of confusion in a tsconfig.json.

module controls the output format: ESNext leaves import/export alone for a bundler to handle, NodeNext follows the type field in your package.json, CommonJS emits require. moduleResolution controls how the compiler finds a module in the first place.

The two settings that matter now:

Getting this pair wrong is what produces "cannot find module" on a package that is clearly installed. The package ships modern exports conditions, your moduleResolution predates them, and the compiler never looks in the right place. The ESM and CommonJS page covers what each format means for the emitted output.

outDir, rootDir, include and exclude

outDir is where emitted JavaScript goes. rootDir pins the input root so the output tree mirrors the source tree — without it, adding a file above your source directory silently reshapes dist.

include and exclude decide which files start the compilation. That word matters: they do not decide what gets type-checked. Anything imported by an included file is pulled in regardless. Excluding a folder does not stop its errors from reaching you if something in src imports it.

exclude only ever narrows include. It cannot remove a file listed in files, and node_modules is already excluded by default — skipLibCheck: true is the setting that actually stops .d.ts files in dependencies from being checked, and it belongs in almost every config.

paths and baseUrl

paths is how import { db } from '@/lib/db' resolves to a real file. It maps an import pattern to one or more locations on disk:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

One catch: paths only teaches the type checker where things live. It does not rewrite the emitted imports. If tsc does your build, the output will still contain @/lib/db and Node will fail to find it at runtime. That is fine when a bundler resolves the aliases too — it needs the same mapping in its own config — and a problem when nothing else does. With moduleResolution: "bundler", baseUrl is optional and paths alone is enough.

jsx

If you have .tsx files, jsx is what makes them compile. "preserve" leaves the JSX in place for a bundler or framework to transform, which is what Next.js and Vite want. "react-jsx" emits the modern automatic runtime directly, for projects compiling with tsc alone. Full details on the extension itself are on the .tsx file page.

esModuleInterop and isolatedModules

esModuleInterop: true fixes default imports from CommonJS packages, so import express from 'express' works instead of requiring the import * as form. It is on in every sane config and off only in legacy ones.

isolatedModules: true makes TypeScript reject anything a single-file transpiler like esbuild or SWC cannot handle — mostly re-exported types without the type keyword. If a bundler compiles your code, turn it on so tsc catches those cases before the bundler does.

extends and Project References

A monorepo should not repeat the same twenty lines in every package. Put them in a shared base and point at it:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

compilerOptions merge key by key, with the child winning. Everything else — include, exclude, files — is replaced wholesale, not merged. Relative paths in the base resolve relative to the base file, except for exclude and include, which resolve relative to the config doing the extending. That asymmetry surprises people.

Project references ("references": [{ "path": "../core" }]) go one step further and let tsc --build compile packages in dependency order with incremental caching. Worth it in a large monorepo, overkill in a single app.

Debugging a Config

When behaviour does not match what you read in the file, ask the compiler what it actually resolved:

[object Object]

That prints the fully merged config, every extends applied and every default filled in. It answers "is this option even on?" in one command, which beats reading an inheritance chain by hand.

The Short Version

A good tsconfig.json is small. Set strict: true and mean it, add noUncheckedIndexedAccess, pick the module/moduleResolution pair that matches your runtime rather than copying one from a tutorial, and set target to the lowest version you genuinely need. Leave skipLibCheck on so other people's type definitions are not your problem.

Everything else in the option reference exists for a specific situation. Until you hit that situation, the shortest config that type-checks your code is the right one. From here, the utility types reference is a good next stop once your project is compiling cleanly.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your tsconfig.json explained knowledge to the test with these related challenges.

#4Pick
Easy
#3312Parameters
Easy
#7Readonly
Easy

Related Concepts

Concepts that build on or relate to tsconfig.json explained.

What Is ESM? ES Modules ExplainedWhat Is a .tsx File?TypeScript OptionalTypeScript ClassesTypeScript DecoratorsTypeScript Utility Types