Overview
tsconfig.json controls every type check that hits your codebase. Most teams copy a config from a starter and never touch it; that is the wrong default. Pick the options that match the runtime and build pipeline, set them explicitly, and review them when the toolchain changes. Read typescript-strict-mode for the strict rationale; this page covers the rest of the surface.
The baseline config
For a typical TypeScript 5.5+ project that targets Node 22 or a modern bundler, this is the starting point.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src"]
}Every option here is load-bearing. The rest of this page explains which ones to change for which target.
Set target to what the runtime supports
target controls the JS syntax that tsc (or a bundler honoring this option) downlevels to. Set it to the lowest version your runtime supports, not the highest.
- Modern bundlers (Vite, esbuild, swc) targeting evergreen browsers:
ES2022or higher. - Node 22 server code:
ES2023. - A package published to npm for broad consumption:
ES2020so older Node 18 consumers can read it without further downlevel.
target does not polyfill runtime APIs; it only controls syntax. For runtime polyfills, configure the bundler.
Pick module and moduleResolution together
module controls the emitted module format; moduleResolution controls how imports are looked up. They must agree.
- Bundled apps (Vite, Next.js, Astro):
"module": "ESNext"and"moduleResolution": "Bundler". The bundler resolves;tsconly type-checks. - Modern Node ESM (Node 22,
"type": "module"):"module": "NodeNext"and"moduleResolution": "NodeNext". Imports need explicit.jsextensions. - Library targeting both:
"module": "NodeNext"with a dual-publish build step.
"moduleResolution": "Node" (the old default) is deprecated; switch to Bundler or NodeNext explicitly. See typescript for the ESM-only rule.
Set esModuleInterop and skipLibCheck to true
These two pull their weight on every project.
esModuleInterop: trueletsimport express from "express"work even when the upstream module is CommonJS. Without it, you writeimport * as express from "express"and the runtime breaks.skipLibCheck: trueskips type-checking inside.d.tsfiles innode_modules. The win is a 5x to 10x speedup on coldtscruns. The cost is that a bug in a dependency’s types can sneak through; the cost is worth paying.
Both are in the baseline above. Set them and forget them.
Use verbatimModuleSyntax to keep imports honest
verbatimModuleSyntax: true forces you to mark type-only imports with import type. The runtime import then matches what tsc emits exactly; no surprise erasure.
// With verbatimModuleSyntax: true
import type { User } from "./types"
import { loadUser } from "./users"
// Without it, this might compile but emit weirdly:
// import { User, loadUser } from "./users"This flag also catches the bug where you import a type from a file that has side effects; without import type, the file is loaded at runtime for no reason. See typescript-types-vs-interfaces for the type-vs-runtime distinction.
Turn on forceConsistentCasingInFileNames
forceConsistentCasingInFileNames: true makes the compiler reject imports that differ from the filesystem casing. Without it, import { x } from "./Foo" compiles on macOS (case-insensitive) and breaks on Linux CI (case-sensitive).
This is a zero-cost setting that prevents a class of “works on my machine” bugs. Always on.
Use isolatedModules when a bundler emits
isolatedModules: true makes tsc reject syntax that bundlers like esbuild and swc cannot compile file-by-file (const enum, certain re-exports). The flag is the contract between tsc for type-checking and a bundler for emit.
{
"compilerOptions": {
"isolatedModules": true,
"noEmit": true
}
}If a bundler is in the build chain, set isolatedModules: true and noEmit: true. tsc --noEmit becomes the type check; the bundler handles output.
Use a base config plus per-package overrides
For anything larger than a single package, use extends to share a base.
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}
// packages/api/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist"
},
"include": ["src"]
}Each package picks its own module, outDir, and include. The base owns the strictness flags. See monorepo for the wider layout rules.
Use project references for monorepos
In a monorepo where packages depend on each other, references tell tsc to build them in order and reuse intermediate results.
// packages/app/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"references": [
{ "path": "../shared" },
{ "path": "../db" }
]
}tsc --build (note --build, not the bare command) walks the references, builds in dependency order, and caches. The first build is the same speed as without references; every subsequent build skips packages whose inputs did not change. For large monorepos this is the difference between a 90-second typecheck and a 5-second one.
Keep test config separate
Tests need different include paths, sometimes different lib entries, and looser rules on unused vars. Put them in tsconfig.test.json that extends the main config; do not pollute the production config with test-only relaxations. See testing for the wider testing rules.