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#
| Field | Purpose |
|---|---|
compilerOptions | Compiler behavior settings |
include | Glob patterns for files to include |
exclude | Glob patterns to exclude (defaults: node_modules, outDir) |
files | Explicit list of files (overrides include) |
extends | Path to a base tsconfig to inherit from |
references | Sub-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#
| Value | Use case |
|---|---|
CommonJS | Node.js with require() |
ESNext | Bundlers (Vite, esbuild, Webpack) |
NodeNext | Node.js with native ESM (.mjs / "type": "module") |
Node16 | Same as NodeNext but pinned to Node 16 semantics |
Preserve | Keep the module format as authored (TS 5.4+) |
moduleResolution values#
| Value | Matches |
|---|---|
node | Classic Node.js require() resolution (legacy) |
bundler | Vite, esbuild, Webpack — allows extensionless imports |
node16 / nodenext | Native Node.js ESM — requires explicit .js extensions in imports |
[!WARNING]
module: "NodeNext"requires import paths in your source to use.jsextensions 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.
| Value | Minimum runtime |
|---|---|
ES2020 | Node 14+, modern browsers |
ES2022 | Node 16+, modern browsers |
ESNext | Latest supported features (tracks TS release) |
Declaration and map options#
| Option | Effect |
|---|---|
declaration | Emit .d.ts type declaration files |
declarationMap | Emit .d.ts.map files (go-to-definition jumps to source) |
sourceMap | Emit .js.map for runtime debugging |
inlineSourceMap | Embed 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"
}
}
| Value | Description |
|---|---|
react | Classic React.createElement transform (React 16 and older) |
react-jsx | New JSX transform (React 17+, no import required) |
react-jsxdev | Same as react-jsx with dev runtime (extra warnings) |
preserve | Keep 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: truein the main tsconfig — Vite handles the actual compilation. Usetsc --noEmitin 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#
| Flag | Catches |
|---|---|
noImplicitReturns | A function with mixed return / no return paths |
noFallthroughCasesInSwitch | Missing break in switch cases |
noUnusedLocals | Declared-but-unused local variables |
noUnusedParameters | Declared-but-unused function parameters (prefix with _ to allow) |
allowUnreachableCode: false | Code after return / throw |
allowUnusedLabels: false | Labels not referenced by any break/continue |
noPropertyAccessFromIndexSignature | obj.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
| Option | Effect |
|---|---|
sourceMap: true | Emit external .js.map file |
inlineSourceMap: true | Embed source map at the bottom of .js as a data URL |
inlineSources: true | Include 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
}
}
| Option | Effect |
|---|---|
declaration | Emit .d.ts files |
declarationMap | Emit .d.ts.map — lets editors “go to definition” jump to source |
emitDeclarationOnly | Skip .js emit entirely (for projects that bundle JS separately) |
declarationDir | Override 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 value | Output for <div /> | When to use |
|---|---|---|
react | React.createElement('div') | React 16 and older |
react-jsx | import { jsx } from 'react/jsx-runtime'; jsx('div') | React 17+ (no React import needed in source) |
react-jsxdev | Same as react-jsx with dev runtime | Development 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
}
}
| Option | When to use |
|---|---|
experimentalDecorators: true | Legacy decorator API — Angular, NestJS, TypeORM, class-validator |
| (no flag) | TC39 standard decorators (TS 5.0+) — modern framework-agnostic code |
emitDecoratorMetadata: true | Stores parameter types at runtime via Reflect.metadata; requires experimentalDecorators |
useDefineForClassFields: true | Use 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.
| Package | Targets |
|---|---|
@tsconfig/node20 | Node 20 with NodeNext modules and strict checks |
@tsconfig/node22 | Node 22 with the latest module settings |
@tsconfig/strictest | Every strict flag turned on, including noUncheckedIndexedAccess and exactOptionalPropertyTypes |
@tsconfig/recommended | Sensible defaults; less aggressive than strictest |
@tsconfig/vite-react | Vite + React app with jsx: "react-jsx" and moduleResolution: "bundler" |
@tsconfig/next | Next.js apps |
@tsconfig/remix | Remix apps |
@tsconfig/svelte | Svelte projects |
@tsconfig/deno | Deno scripts |
@tsconfig/bun | Bun-first projects |
@tsconfig/cloudflare-workers | Cloudflare Workers with WebWorker lib and Bundler resolution |
@tsconfig/create-react-app | CRA-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/Button → src/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:
- 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)),
},
},
});
- esbuild — use the
tsconfig-pathsplugin:
npm install -D esbuild esbuild-plugin-tsconfig-paths
Output:
added 2 packages in 0.4s
- Node directly — use
tsconfig-paths/register:
node --import tsconfig-paths/register dist/index.js
Output: (none — exits 0 on success)
- Bun — Bun reads
tsconfig.jsonpathsnatively:
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:
| Value | Adds |
|---|---|
ES2015 … ES2024 | ECMAScript globals for that year (Promise, Map, Array.prototype.flat, etc.) |
ESNext | Latest spec features (proposals at stage 3+) |
DOM | window, document, HTMLElement, etc. |
DOM.Iterable | NodeList and HTMLCollection iteration support |
WebWorker | Worker globals (self, postMessage) |
WebWorker.Iterable | Iteration support for WebWorker globals |
ScriptHost | Windows ScriptHost APIs (legacy) |
decorators | Symbol.metadata (TS 5.2+) |
decorators.legacy | Legacy 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#
module: NodeNextwithout.jsextensions — every relative import must end in.jseven though sources are.ts. Add them, or switch tomoduleResolution: bundler.pathsworking in the editor but breaking at runtime — the editor usespaths; Node doesn’t. Either set uptsconfig-paths/registeror mirror the alias in the bundler.extendsfrom a package not indevDependencies— CI fails on missing base config. Always declare@tsconfig/*in devDependencies.composite: truewithoutoutDirandrootDir— TS errors because composite projects must emit. Set both.skipLibCheck: falseon a large codebase — every@types/*package’s bugs become your errors. Default totrue; only flip tofalseon CI before publishing.target: ES5with modern syntax —async/awaitcompiled to ES5 generates a huge polyfill helper. PickES2017or later.strict: trueplusnoImplicitAny: false— TypeScript silently re-enablesnoImplicitAnybecause it’s part ofstrict. Either keepstrict: true(turning everything on) or setstrict: falseand pick individual flags.- Setting
modulewithoutmoduleResolution— defaults can surprise:module: NodeNextdefaultsmoduleResolutiontoNodeNext, butmodule: ESNextdefaults it toClassic(effectively broken). Always set both. includeandfilestogether —filestakes precedence and is exact-match. If you setfiles: ["src/main.ts"], only that one file (plus its transitive imports) compiles, regardless ofinclude.- Comments in tsconfig.json — TS 1.7+ supports JSONC (JSON with comments), but some external tools (older
prettier, some linters) reject them. Usetsconfig.jsonfor the file but author with// ...comments freely —tscis 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.