skip to content

tsconfig.json Reference

Complete reference for tsconfig.json — compiler options for type checking, module resolution, output, paths, and JSX. Includes ready-to-use presets for Node 20, browser libraries, and Vite/React apps.

21 min read 80 snippets deep dive

tsconfig.json Reference#

What it is#

tsconfig.json is the TypeScript project configuration file placed at the root of a TypeScript project. It controls which files are included in the compilation and how the compiler processes them. Options are split across two concerns: which files to compile (include, exclude, files, references) and how to compile them (compilerOptions).

Basic structure#

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true,
    "outDir": "dist"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"],
  "references": []
}

Top-level fields#

FieldPurpose
compilerOptionsCompiler behavior settings
includeGlob patterns for files to include
excludeGlob patterns to exclude (defaults: node_modules, outDir)
filesExplicit list of files (overrides include)
extendsPath to a base tsconfig to inherit from
referencesSub-project references for tsc --build

Type checking options#

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true
  }
}

Key options explained#

strict — Enables a bundle of strict checks. Equivalent to setting all of the following to true: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables. Always enable this.

noImplicitAny — Errors when TypeScript infers type any due to missing annotations. Forces explicit typing.

strictNullChecks — Makes null and undefined distinct types. Without this, every type is implicitly nullable — a common source of runtime bugs.

strictFunctionTypes — Enforces stricter checking of function parameter types (contravariance). Catches subtle callback type errors.

noUncheckedIndexedAccess — Array index access returns T | undefined instead of T. Prevents silent out-of-bounds bugs.

// noUncheckedIndexedAccess: true
const arr = [1, 2, 3];
const first = arr[0]; // type: number | undefined
if (first !== undefined) {
  console.log(first.toFixed(2)); // safe
}

exactOptionalPropertyTypes — Distinguishes between a property being absent and being explicitly set to undefined.

// exactOptionalPropertyTypes: true
interface Config {
  timeout?: number;
}
const c: Config = { timeout: undefined }; // Error — must omit the key instead

Module and output options#

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "removeComments": false
  }
}

module values#

ValueUse case
CommonJSNode.js with require()
ESNextBundlers (Vite, esbuild, Webpack)
NodeNextNode.js with native ESM (.mjs / "type": "module")
Node16Same as NodeNext but pinned to Node 16 semantics
PreserveKeep the module format as authored (TS 5.4+)

moduleResolution values#

ValueMatches
nodeClassic Node.js require() resolution (legacy)
bundlerVite, esbuild, Webpack — allows extensionless imports
node16 / nodenextNative Node.js ESM — requires explicit .js extensions in imports

[!WARNING] module: "NodeNext" requires import paths in your source to use .js extensions even though the source files are .ts. This is because Node.js resolves the compiled output, not the source.

target values#

Sets the JavaScript version of the emitted output. TypeScript will down-compile any syntax newer than the target.

ValueMinimum runtime
ES2020Node 14+, modern browsers
ES2022Node 16+, modern browsers
ESNextLatest supported features (tracks TS release)

Declaration and map options#

OptionEffect
declarationEmit .d.ts type declaration files
declarationMapEmit .d.ts.map files (go-to-definition jumps to source)
sourceMapEmit .js.map for runtime debugging
inlineSourceMapEmbed source map inside .js instead of separate file

Path and import options#

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/app/*"],
      "@lib/*": ["src/lib/*"],
      "@/*": ["src/*"]
    },
    "rootDirs": ["src", "generated"]
  }
}

baseUrl — The base directory for non-relative module names. Usually set to . (project root) or src.

paths — Map import aliases to file paths. Must be used alongside a bundler or a path resolver plugin (tsconfig-paths) at runtime since tsc does not rewrite import paths.

rootDirs — Tells TypeScript to treat multiple directories as a single virtual root. Useful when generated files live in a separate directory.

JSX#

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  }
}
ValueDescription
reactClassic React.createElement transform (React 16 and older)
react-jsxNew JSX transform (React 17+, no import required)
react-jsxdevSame as react-jsx with dev runtime (extra warnings)
preserveKeep JSX as-is for a bundler to handle (Vite, esbuild)

For Solid.js: "jsxImportSource": "solid-js/h". For Preact: "jsxImportSource": "preact".

Extending a base config#

The extends field inherits all options from another tsconfig and allows overriding individual fields.

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

Install community base configs from npm:

npm install -D @tsconfig/node20
npm install -D @tsconfig/strictest
npm install -D @tsconfig/vite-react

Output: (none — exits 0 on success)

Common presets#

Node 20 application#

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Browser library (published to npm)#

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Vite + React application#

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

tsconfig.node.json (for Vite config file itself):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "composite": true
  },
  "include": ["vite.config.ts"]
}

[!TIP] For Vite projects, set noEmit: true in the main tsconfig — Vite handles the actual compilation. Use tsc --noEmit in CI only for type-checking.

Complete option quick-reference#

{
  "compilerOptions": {
    // Type checking
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,

    // Module
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,

    // Output
    "target": "ES2022",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,

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

    // Misc
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

How tsc reads tsconfig.json#

When you run tsc with no arguments, the compiler walks up from the current working directory looking for a tsconfig.json. Once found, it parses the JSON, resolves extends chains, expands include/exclude/files globs, and finally invokes the compilation. Knowing the exact order helps debug missing-file errors and surprise emit shapes.

npx tsc --showConfig

Output:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true
  },
  "files": [],
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]
}

--showConfig is the canonical way to inspect what tsc actually sees — after extends resolution and after every glob is expanded. If your build emits the wrong shape, this is the first command to run.

To run tsc against a different config without changing directories:

npx tsc -p tsconfig.build.json

Output: (none — exits 0 on success)

The -p (project) flag accepts a directory (looking for tsconfig.json inside) or a direct path to any .json file.

Type checking deep dive#

Each of the strict-family flags addresses a specific category of subtle bugs. Understanding what each one catches makes it easier to migrate a legacy codebase one flag at a time.

strict: the bundle#

strict: true is shorthand for enabling seven sub-flags simultaneously. The list grows occasionally — TypeScript may add a new strict check in a future release. Enabling strict opts in to whatever the current TypeScript version considers strict.

{
  "compilerOptions": {
    "strict": true
  }
}

Equivalent to setting all of these explicitly:

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true
  }
}

For migrating an existing JS codebase, enable strict: true then disable individual flags as needed: "strict": true, "noImplicitAny": false. This is the recommended approach over enabling flags one by one — it lets future TS versions add new strict checks that your team has opted into.

useUnknownInCatchVariables#

Before TS 4.4, catch (e) typed e as any. With useUnknownInCatchVariables: true (part of strict), e is unknown, forcing a runtime narrow before use.

try {
  doSomething();
} catch (e) {
  if (e instanceof Error) {
    console.error(e.message);
  } else {
    console.error("Unknown error:", e);
  }
}

Without the flag, e.message would compile without checks and crash if e is a number or null. The narrow is required for type safety.

strictPropertyInitialization#

Class fields must be initialised in the constructor or have an initialiser:

class User {
  // Error — strictPropertyInitialization
  name: string;

  // OK — definite assignment assertion
  email!: string;

  // OK — initialiser
  role: string = "member";

  // OK — initialised in constructor
  id: number;
  constructor() {
    this.id = Math.random();
  }
}

The ! (definite assignment assertion) tells TS “I promise this is initialised before any read”. Use sparingly — it’s an escape hatch.

exactOptionalPropertyTypes#

Without this flag, an optional property name?: string is treated as string | undefined. With it, the property must either be absent or hold a string — explicitly passing undefined errors.

interface Config { timeout?: number; }

// Default (exactOptionalPropertyTypes: false)
const a: Config = { timeout: undefined }; // OK

// Strict (exactOptionalPropertyTypes: true)
const b: Config = { timeout: undefined };
// Error TS2375: Type '{ timeout: undefined; }' is not assignable to type 'Config'
//   with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type
//   of the target.
const c: Config = {};                     // OK — absent
const d: Config = { timeout: 1000 };      // OK — present with value

This flag catches a common bug: { ...defaults, timeout: undefined } silently overrides the default. Most teams enable it; some find it too noisy on existing codebases.

noUncheckedIndexedAccess#

Index access (arr[0], obj["key"]) returns T | undefined instead of T. Prevents silent out-of-bounds bugs.

// noUncheckedIndexedAccess: false (default)
const arr = [1, 2, 3];
const first = arr[100]; // type: number, value: undefined — silent bug

// noUncheckedIndexedAccess: true
const arr2 = [1, 2, 3];
const first2 = arr2[100]; // type: number | undefined — must narrow
if (first2 !== undefined) {
  console.log(first2.toFixed(2));
}

Pair this with Object.entries() and Object.keys() for safer iteration:

const record: Record<string, number> = { a: 1, b: 2 };
const val = record["c"]; // type: number | undefined

noImplicitOverride#

Forces explicit override keyword on method overrides in subclasses. Catches typo-renamed methods that silently no longer override anything.

class Base {
  greet() { return "hi"; }
}

class Child extends Base {
  // Error — missing 'override' keyword
  greet() { return "hello"; }

  // OK
  override greet() { return "hello"; }
}

If you rename Base.greet to Base.greetUser, every Child.greet becomes a new method instead of an override — noImplicitOverride catches this immediately.

Other useful checks#

FlagCatches
noImplicitReturnsA function with mixed return / no return paths
noFallthroughCasesInSwitchMissing break in switch cases
noUnusedLocalsDeclared-but-unused local variables
noUnusedParametersDeclared-but-unused function parameters (prefix with _ to allow)
allowUnreachableCode: falseCode after return / throw
allowUnusedLabels: falseLabels not referenced by any break/continue
noPropertyAccessFromIndexSignatureobj.dynamic when only obj["dynamic"] should be allowed
{
  "compilerOptions": {
    "strict": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true
  }
}

Source maps and declaration files#

The emit options control what tsc writes to disk alongside the compiled JavaScript. Each artifact has a distinct purpose.

Source maps#

A source map is a .js.map file that maps positions in the compiled JavaScript back to positions in the original TypeScript. Browsers, Node debuggers, and stack-trace tools read it to show the original .ts in error messages.

{
  "compilerOptions": {
    "sourceMap": true
  }
}
tsc
ls dist/

Output:

index.js
index.js.map
util.js
util.js.map
OptionEffect
sourceMap: trueEmit external .js.map file
inlineSourceMap: trueEmbed source map at the bottom of .js as a data URL
inlineSources: trueInclude the original .ts content in the source map itself
sourceRoot: "/src"Prefix in the map’s sources array (useful when serving from a CDN)
mapRoot: "/maps"Tell consumers where to fetch the .map files from

In Node, run with --enable-source-maps to get TS line numbers in stack traces:

node --enable-source-maps dist/index.js

Output:

Error: Boom
    at greet (src/index.ts:7:9)
    at main (src/index.ts:12:3)

Without the flag, the stack would point at dist/index.js.

Declaration files#

declaration: true emits a .d.ts for every .ts source file. These are the “headers” that downstream consumers see when they import your library.

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": false
  }
}
OptionEffect
declarationEmit .d.ts files
declarationMapEmit .d.ts.map — lets editors “go to definition” jump to source
emitDeclarationOnlySkip .js emit entirely (for projects that bundle JS separately)
declarationDirOverride the output directory for .d.ts (defaults to outDir)

A library’s typical declaration-emit setup:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "outDir": "dist",
    "rootDir": "src"
  }
}
tsc
ls dist/

Output:

index.d.ts
index.d.ts.map
index.js
index.js.map
util.d.ts
util.d.ts.map
util.js
util.js.map

emitDeclarationOnly: true is useful when a faster bundler (esbuild, swc, Bun) handles JS emit but you still need TypeScript’s .d.ts:

# Emit only .d.ts files
tsc --emitDeclarationOnly

# Build JS with a fast bundler in parallel
esbuild src/index.ts --bundle --outdir=dist --format=esm

Output: (none — exits 0 on success)

JSX deep dive#

The jsx option controls how .tsx files are compiled. Each value produces dramatically different output.

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "react",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment"
  }
}
jsx valueOutput for <div />When to use
reactReact.createElement('div')React 16 and older
react-jsximport { jsx } from 'react/jsx-runtime'; jsx('div')React 17+ (no React import needed in source)
react-jsxdevSame as react-jsx with dev runtimeDevelopment builds with line/column tracking
preserve<div /> (untouched)Vite/esbuild that handles JSX downstream
react-native<div /> (untouched, but .js extension)React Native bundlers

The jsxImportSource option is for non-React JSX runtimes:

// Preact
{ "jsx": "react-jsx", "jsxImportSource": "preact" }

// Solid
{ "jsx": "preserve", "jsxImportSource": "solid-js" }

// Emotion (classic JSX)
{ "jsx": "react", "jsxFactory": "jsx", "jsxFragmentFactory": "Fragment" }

For Astro projects, jsx: "preserve" is the default — Astro’s own preprocessor handles .astro and JSX in one pass.

Decorators and metadata#

Decorators live in their own compilation pass. Two distinct decorator systems exist; the compiler options differ.

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "useDefineForClassFields": true
  }
}
OptionWhen to use
experimentalDecorators: trueLegacy decorator API — Angular, NestJS, TypeORM, class-validator
(no flag)TC39 standard decorators (TS 5.0+) — modern framework-agnostic code
emitDecoratorMetadata: trueStores parameter types at runtime via Reflect.metadata; requires experimentalDecorators
useDefineForClassFields: trueUse ES2022 class-field semantics (define not Object.assign)

You cannot mix the legacy and standard APIs in the same project. See decorators for the full comparison.

Project references in detail#

references and composite: true together turn a single tsconfig into a graph of incrementally-built sub-projects. The compiler can skip whole projects whose inputs are unchanged, dramatically speeding up monorepo builds.

// tsconfig.json (root)
{
  "files": [],
  "references": [
    { "path": "./packages/shared" },
    { "path": "./packages/ui" },
    { "path": "./packages/api" },
    { "path": "./apps/web" }
  ]
}

Each referenced project sets composite: true:

// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true
  },
  "include": ["src/**/*"]
}

Build the whole graph in dependency order:

npx tsc --build

Output:

(no output — exit code 0)

See project references for the deep dive on --build, .tsbuildinfo caching, and Turborepo/Nx integration.

The @tsconfig/* ecosystem#

Maintaining a custom tsconfig per project is repetitive. The @tsconfig/* packages on npm are community-maintained presets you can extends from.

PackageTargets
@tsconfig/node20Node 20 with NodeNext modules and strict checks
@tsconfig/node22Node 22 with the latest module settings
@tsconfig/strictestEvery strict flag turned on, including noUncheckedIndexedAccess and exactOptionalPropertyTypes
@tsconfig/recommendedSensible defaults; less aggressive than strictest
@tsconfig/vite-reactVite + React app with jsx: "react-jsx" and moduleResolution: "bundler"
@tsconfig/nextNext.js apps
@tsconfig/remixRemix apps
@tsconfig/svelteSvelte projects
@tsconfig/denoDeno scripts
@tsconfig/bunBun-first projects
@tsconfig/cloudflare-workersCloudflare Workers with WebWorker lib and Bundler resolution
@tsconfig/create-react-appCRA-compatible (legacy)

Install and extend:

npm install -D @tsconfig/node20 @tsconfig/strictest

Output:

added 2 packages in 0.6s
{
  "extends": ["@tsconfig/node20/tsconfig.json", "@tsconfig/strictest/tsconfig.json"],
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

The right-most extends wins on conflicts (TS 5.0+). Layer presets: a base (@tsconfig/node20) plus a strictness modifier (@tsconfig/strictest).

Verify the merged config:

npx tsc --showConfig | head -20

Output:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}

paths and bundler agreement#

paths aliases (@/components/Buttonsrc/components/Button.ts) only affect TypeScript’s type-checking — the emitted JavaScript still contains the literal alias. At runtime, your bundler (or a path-resolver shim) must agree on the same mapping or the import will fail.

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

The four runtime stories:

  1. Vite — mirror in vite.config.ts:
import { defineConfig } from "vite";
import { fileURLToPath, URL } from "node:url";

export default defineConfig({
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
      "@components": fileURLToPath(new URL("./src/components", import.meta.url)),
      "@lib": fileURLToPath(new URL("./src/lib", import.meta.url)),
    },
  },
});
  1. esbuild — use the tsconfig-paths plugin:
npm install -D esbuild esbuild-plugin-tsconfig-paths

Output:

added 2 packages in 0.4s
  1. Node directly — use tsconfig-paths/register:
node --import tsconfig-paths/register dist/index.js

Output: (none — exits 0 on success)

  1. Bun — Bun reads tsconfig.json paths natively:
bun run src/index.ts

Output:

Hello from TypeScript

If paths is set but the bundler doesn’t know about it, you get a runtime Cannot find module '@/foo' error — code that type-checks but doesn’t run. Always keep the two sides in sync, or use workspaces instead (see project references).

skipLibCheck — the universal escape hatch#

skipLibCheck: true is the single most-recommended option for shipping projects. It tells tsc not to type-check .d.ts files in node_modules — only your source.

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

Without it, a single buggy @types/foo package can break your entire build. With it, your code still gets full type-checking, but the upstream .d.ts files are taken at face value.

# Without skipLibCheck — slow and brittle
time npx tsc --noEmit

Output:

src/main.ts:1:10 - error TS7016: Could not find a declaration for ...
node_modules/@types/some-pkg/index.d.ts:42:10 - error TS2304: Cannot find ...

Found 3 errors in 2 files.

real    0m4.821s
# With skipLibCheck — fast and tolerant
time npx tsc --noEmit

Output:

src/main.ts:1:10 - error TS7016: Could not find a declaration for ...

Found 1 error in 1 file.

real    0m1.412s

The trade-off: if you author a library and ship .d.ts files, skipLibCheck: true means consumers won’t see errors in your declarations. Always run a CI step with skipLibCheck: false before publishing.

Lib option — built-in type libraries#

The lib option lists which TypeScript built-in libraries to include. It’s separate from target because some runtimes (browsers, web workers, Node) expose different globals.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  }
}

Common lib values:

ValueAdds
ES2015ES2024ECMAScript globals for that year (Promise, Map, Array.prototype.flat, etc.)
ESNextLatest spec features (proposals at stage 3+)
DOMwindow, document, HTMLElement, etc.
DOM.IterableNodeList and HTMLCollection iteration support
WebWorkerWorker globals (self, postMessage)
WebWorker.IterableIteration support for WebWorker globals
ScriptHostWindows ScriptHost APIs (legacy)
decoratorsSymbol.metadata (TS 5.2+)
decorators.legacyLegacy decorator metadata typings

For a Node-only project:

{
  "compilerOptions": {
    "lib": ["ES2022"]
  }
}

For a Cloudflare Worker:

{
  "compilerOptions": {
    "lib": ["ES2022", "WebWorker"],
    "types": ["@cloudflare/workers-types"]
  }
}

If you omit lib, TypeScript defaults to a sensible set based on target — including DOM. Browser projects don’t need to set lib explicitly; Node projects should set lib: ["ES2022"] (no DOM) to avoid mistakenly using browser globals.

Common pitfalls#

  1. module: NodeNext without .js extensions — every relative import must end in .js even though sources are .ts. Add them, or switch to moduleResolution: bundler.
  2. paths working in the editor but breaking at runtime — the editor uses paths; Node doesn’t. Either set up tsconfig-paths/register or mirror the alias in the bundler.
  3. extends from a package not in devDependencies — CI fails on missing base config. Always declare @tsconfig/* in devDependencies.
  4. composite: true without outDir and rootDir — TS errors because composite projects must emit. Set both.
  5. skipLibCheck: false on a large codebase — every @types/* package’s bugs become your errors. Default to true; only flip to false on CI before publishing.
  6. target: ES5 with modern syntaxasync/await compiled to ES5 generates a huge polyfill helper. Pick ES2017 or later.
  7. strict: true plus noImplicitAny: false — TypeScript silently re-enables noImplicitAny because it’s part of strict. Either keep strict: true (turning everything on) or set strict: false and pick individual flags.
  8. Setting module without moduleResolution — defaults can surprise: module: NodeNext defaults moduleResolution to NodeNext, but module: ESNext defaults it to Classic (effectively broken). Always set both.
  9. include and files togetherfiles takes precedence and is exact-match. If you set files: ["src/main.ts"], only that one file (plus its transitive imports) compiles, regardless of include.
  10. Comments in tsconfig.json — TS 1.7+ supports JSONC (JSON with comments), but some external tools (older prettier, some linters) reject them. Use tsconfig.json for the file but author with // ... comments freely — tsc is happy.

Real-world recipes#

Strict tsconfig for a new project#

The baseline that catches the most bugs without being unreasonable. Drop this into a new project and tune from there.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitReturns": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
npx tsc --noEmit

Output:

(no output — exit code 0)

Bun + TypeScript + Vite project#

Bun reads tsconfig.json natively. For a Vite-based front-end built with Bun, the config looks slightly different — moduleResolution: "bundler", no emit, JSX preserved for Vite to transform.

{
  "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
    "types": ["@types/bun", "vite/client"]
  },
  "include": ["src"]
}
bun add -d typescript @tsconfig/strictest @types/bun

Output:

bun add v1.2.18

 installed typescript@5.4.5
 installed @tsconfig/strictest@2.0.5
 installed @types/bun@1.1.5

 3 packages installed [127.00ms]

Cloudflare Workers tsconfig#

Cloudflare Workers run on V8 isolates — no Node, no DOM, just fetch-style request handling. Use the @cloudflare/workers-types package for Worker globals.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "WebWorker"],
    "types": ["@cloudflare/workers-types"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true,
    "noEmit": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src/**/*", "worker-configuration.d.ts"]
}
npx tsc --noEmit
npx wrangler deploy

Output:

Uploaded my-worker (1.42 sec)
Published my-worker (0.32 sec)
  https://my-worker.example.workers.dev

Split tsconfigs for source + tests#

Tests usually need different lib/types than source — @types/jest, vitest/globals, @types/node. Split into two configs and use extends.

// tsconfig.json — source
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["**/*.test.ts", "**/*.spec.ts"]
}
// tsconfig.test.json — tests
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "noEmit": true,
    "types": ["vitest/globals", "@types/node"]
  },
  "include": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}
npx tsc --noEmit -p tsconfig.test.json

Output:

(no output — exit code 0)

The test config inherits everything from the main config, adds Vitest globals, and excludes nothing — it picks up tests that tsconfig.json skips.

Migrate a JavaScript project incrementally#

Existing JS codebase, no rewrite. Use allowJs: true + checkJs: false to ship without errors; flip checkJs: true per-file with // @ts-check.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowJs": true,
    "checkJs": false,
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

In any .js file, add the magic comment to opt in:

// @ts-check
/**
 * @param {string} name
 * @returns {string}
 */
function greet(name) {
  return `Hello, ${name}!`;
}

tsc --noEmit now type-checks just this file using JSDoc annotations. See project references for per-directory migration.

Verify what your tsconfig actually does#

Three commands answer “is my config doing what I think”:

# 1. After extends resolution and glob expansion
npx tsc --showConfig

# 2. Every file the compiler will read
npx tsc --listFiles --noEmit | wc -l

# 3. Per-pass timing
npx tsc --diagnostics --noEmit

Output:

{ "compilerOptions": { ... }, "include": [ ... ] }
247
Files:                          47
Lines:                       18421
Parse time:                   0.42s
Bind time:                    0.12s
Check time:                   1.87s
Total time:                   2.41s

If the file count is way higher than expected, check include/exclude and consider tightening types. If check time dominates, look at generic-heavy hot spots.