tsconfig and Tooling
tsconfig.json is the project’s contract with the compiler: which files, which checks, which
JavaScript output. Getting a handful of options right eliminates whole categories of confusion.
Creating and structuring the config
Section titled “Creating and structuring the config”npx tsc --init # writes a heavily commented tsconfig.jsonnpx tsc --showConfig # prints the fully resolved config (after `extends`)A config has three parts: which files to include, compilerOptions, and optionally references.
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}File selection:
include— glob patterns, relative to the config file. Defaults to everything under the config’s directory.exclude— filtersinclude. It does not exclude files that are imported by included files.files— an explicit list; disablesinclude.
extends lets you share a base:
{ "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "outDir": "dist" }}The @tsconfig/* packages on npm publish sensible bases per runtime (node20, node22, strictest,
and so on). extends also accepts an array (later entries win) and relative paths.
The strict family
Section titled “The strict family”"strict": true turns on a group of checks. Turning it on is the single highest-value decision in
a TypeScript project, and each member can be individually overridden.
| Flag | What it does |
|---|---|
noImplicitAny |
Errors when a type falls back to any implicitly |
strictNullChecks |
null/undefined are not members of every type |
strictFunctionTypes |
Contravariant parameter checking for function type positions |
strictBindCallApply |
Checks arguments to bind, call, and apply |
strictPropertyInitialization |
Class fields must be definitely assigned |
noImplicitThis |
Errors when this would be any |
useUnknownInCatchVariables |
catch (e) gives unknown, not any |
alwaysStrict |
Parses in strict mode and emits "use strict" |
strictBuiltinIteratorReturn |
(TS 5.6+) Built-in iterators’ TReturn is undefined, not any |
strictNullChecks
Section titled “strictNullChecks”The one that matters most. Without it, null and undefined are assignable to everything and the
compiler is blind to the most common runtime error in JavaScript.
// strictNullChecks: false — compiles, crashes at runtimefunction greet(name: string) { return name.toUpperCase(); }greet(null);
// strictNullChecks: truegreet(null); // error: Argument of type 'null' is not assignable to parameter of type 'string'noImplicitAny
Section titled “noImplicitAny”function process(data) { // error TS7006: Parameter 'data' implicitly has an 'any' type return data.value;}Explicit any is still allowed — the flag only catches the ones you didn’t ask for.
strictFunctionTypes
Section titled “strictFunctionTypes”Makes function type parameters checked contravariantly (the mathematically correct rule).
type Handler = (e: Event) => void;
const mouseHandler = (e: MouseEvent) => console.log(e.clientX);const h: Handler = mouseHandler;// error under strictFunctionTypes: MouseEvent is not assignable to EventThe error is correct: h could be called with a plain Event, which has no clientX.
useUnknownInCatchVariables
Section titled “useUnknownInCatchVariables”try { risky();} catch (e) { e.message; // error: 'e' is of type 'unknown' if (e instanceof Error) console.error(e.message); // correct}This is right: JavaScript lets you throw anything.
Strictness beyond strict
Section titled “Strictness beyond strict”These are not included in strict and are worth considering individually:
| Flag | Effect |
|---|---|
noUncheckedIndexedAccess |
arr[i] and obj[key] include undefined |
exactOptionalPropertyTypes |
{ a?: string } no longer accepts { a: undefined } |
noImplicitReturns |
Every code path in a function must return |
noFallthroughCasesInSwitch |
Non-empty case must break/return |
noUnusedLocals / noUnusedParameters |
Errors on dead identifiers |
noPropertyAccessFromIndexSignature |
Forces obj['dynamic'] over obj.dynamic for index-signature keys |
allowUnreachableCode: false |
Errors on unreachable statements |
// noUncheckedIndexedAccess: trueconst items = ['a', 'b'];const first = items[0]; // string | undefinedfirst.toUpperCase(); // error: 'first' is possibly 'undefined'console.log(first?.toUpperCase());Output and language level
Section titled “Output and language level”target
Section titled “target”Which JavaScript version to emit. Syntax newer than the target is downlevelled.
{ "target": "ES2022" }ES2022is a good default for Node 18+ and evergreen browsers (class fields,.at(), top-levelawaitin modules,Error.cause).ESNextemits whatever the compiler supports and downlevels nothing.- Older targets (
ES5) produce more code and slower output — only choose one if you truly must support old runtimes.
target also sets the default lib, and it influences useDefineForClassFields (which becomes
true for ES2022 and above, matching real JavaScript class-field semantics).
Which built-in type declarations are available. Setting it overrides the target default.
{ "lib": ["ES2022", "DOM", "DOM.Iterable"] }- Server/Node code: omit
DOM, sodocumentandwindoware correctly unavailable. - Browser code: include
DOMandDOM.Iterable. libdescribes what types exist; it does not polyfill anything. If you targetES5but listES2022,Object.fromEntriestype-checks and then crashes on an old runtime.
module and moduleResolution
Section titled “module and moduleResolution”module controls the emitted import/export syntax; moduleResolution controls how import
specifiers are resolved to files. They must be compatible.
| Scenario | module |
moduleResolution |
|---|---|---|
Node.js, modern (ESM/CJS via package.json type) |
NodeNext |
NodeNext |
| Bundler (Vite, webpack, esbuild, Rollup) | ESNext or Preserve |
Bundler |
| Legacy CommonJS | CommonJS |
Node10 |
NodeNextimplements Node’s real rules:.mts/.ctsextensions, thetypefield inpackage.json,exportsmaps, and mandatory file extensions in relative ESM imports (import './x.js', even fromx.ts).Bundler(TypeScript 5.0+) mirrors what bundlers actually do: extensionless imports,exportsmap support, no CJS/ESM interop policing. Use it only when a bundler handles the output.Preserve(TypeScript 5.4+) leaves import/export syntax exactly as written, for when another tool does the module transformation.
esModuleInterop
Section titled “esModuleInterop”Fixes importing CommonJS modules from ESM syntax.
// esModuleInterop: falseimport * as express from 'express';const app = express(); // error: This expression is not callable
// esModuleInterop: trueimport express from 'express';const app = express(); // worksIt changes the emitted helper code so that a CJS module.exports = fn appears as a default export.
Keep it true unless you have a specific reason not to. allowSyntheticDefaultImports relaxes only
the type check, without changing the emit — esModuleInterop implies it.
paths and baseUrl
Section titled “paths and baseUrl”Import aliases.
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@lib/*": ["src/lib/*"] } }}import { formatDate } from '@/utils/date';Since TypeScript 4.1, paths works without baseUrl; entries are then resolved relative to the
tsconfig file. Node’s own subpath imports ("imports": { "#lib/*": "./src/lib/*.js" } in
package.json) are a runtime-native alternative that needs no extra tooling.
outDir, rootDir, and friends
Section titled “outDir, rootDir, and friends”{ "outDir": "./dist", "rootDir": "./src", "sourceMap": true, "declaration": true, "declarationMap": true, "removeComments": false}rootDir fixes the directory structure mirrored into outDir. Without it, the compiler infers the
common root of all inputs, so adding one file outside src silently changes every output path.
skipLibCheck
Section titled “skipLibCheck”Skips type checking of .d.ts files, including all of node_modules.
{ "skipLibCheck": true }It cuts build time substantially and prevents errors in third-party declarations from blocking you.
The cost: genuine conflicts between library types go unnoticed. tsc --init enables it, and nearly
every real project keeps it on.
isolatedModules and verbatimModuleSyntax
Section titled “isolatedModules and verbatimModuleSyntax”isolatedModules errors on anything that can’t be compiled one file at a time — required if any
tool in your pipeline (esbuild, swc, Babel, Vite, tsx) transpiles per-file, which is nearly all of
them.
// Under isolatedModules, this fails: the transpiler can't tell if `User` is a type.export { User } from './types';
// Correct:export type { User } from './types';import type { User } from './types';verbatimModuleSyntax (TypeScript 5.0) goes further: imports without type are always emitted,
imports with type are always dropped. No more guessing about side-effect imports being elided.
It’s the modern recommendation, and it forces the import type discipline everywhere.
resolveJsonModule
Section titled “resolveJsonModule”{ "resolveJsonModule": true }import pkg from './package.json';pkg.version; // typed from the file's actual contentsFor React and friends:
{ "jsx": "react-jsx" }react-jsx uses the automatic runtime (no import React needed). preserve leaves JSX in the
output for a bundler to handle.
Type checking versus transpiling
Section titled “Type checking versus transpiling”This distinction explains most tooling confusion. There are two separate jobs:
- Type checking — reading every file, resolving types across module boundaries, reporting
errors. Slow, whole-program, only
tsc(and your editor’s language server) does it. - Transpiling — deleting types and rewriting syntax, one file at a time. Fast, and done by
esbuild, swc, Babel,
tsx, Vite, and Node’s type stripping.
The standard setup:
{ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc --noEmit && esbuild src/index.ts --bundle --outfile=dist/index.js", "typecheck": "tsc --noEmit", "typecheck:watch": "tsc --noEmit --watch" }}Run typecheck in CI on every pull request. In the editor, the language server gives you the same
errors live.
npx tsc # compile per tsconfig.jsonnpx tsc --noEmit # check onlynpx tsc --watch # incremental rechecks on savenpx tsc -p tsconfig.build.jsonnpx tsc --diagnostics # timing and memory, for debugging slow buildstsc never runs your code. It reads .ts, writes .js/.d.ts, and exits non-zero on errors
(unless noEmitOnError is false, in which case it still writes output).
Speed options:
{ "incremental": true, "tsBuildInfoFile": "./node_modules/.cache/tsbuildinfo"}incremental caches per-file information so subsequent runs only recheck what changed.
tsx and other runners
Section titled “tsx and other runners”npm install --save-dev tsxnpx tsx src/script.tsnpx tsx watch src/server.tstsx uses esbuild to strip types and runs the result — near-instant startup, zero type checking.
ts-node is the older alternative; by default it does type-check (slower), and ts-node --swc or
--transpileOnly disables that.
Node itself:
node --experimental-strip-types script.ts # Node 22.6+node script.ts # Node 23.6+, on by defaultNode only strips; it does not check, and it rejects non-erasable syntax (enum, namespace,
parameter properties). Pair it with "erasableSyntaxOnly": true so tsc catches those at build
time rather than Node at runtime.
Bundler integration
Section titled “Bundler integration”Typical arrangement for a frontend project:
{ "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "noEmit": true, "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "resolveJsonModule": true }, "include": ["src"]}noEmit: true because the bundler produces the JavaScript; tsc is purely the checker.
Plugins that surface type errors during a bundler build exist — vite-plugin-checker runs tsc in
a worker, and Next.js runs a type check during next build by default. They’re convenient, but the
authoritative check is still tsc --noEmit in CI.
Project references (monorepos)
Section titled “Project references (monorepos)”For multiple packages that depend on each other:
{ "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" }, "references": [{ "path": "../shared" }]}npx tsc -b # build mode: builds dependencies in order, incrementallynpx tsc -b --clean # remove outputsnpx tsc -b --watchcomposite: true (which implies declaration and incremental) makes a project referenceable.
Build mode reads the .d.ts of referenced projects instead of re-checking their sources, which is
what keeps large monorepos fast.
Emitting declarations
Section titled “Emitting declarations”Covered in classes and declarations; the compiler options in one place:
{ "declaration": true, // emit .d.ts "declarationMap": true, // emit .d.ts.map for go-to-source "emitDeclarationOnly": false, // types only, no JS (when a bundler emits the JS) "declarationDir": "./types" // separate output directory (optional)}For libraries, also set "isolatedDeclarations": true (TypeScript 5.5+) if you want declaration
emit to be possible per-file without whole-program inference. It requires explicit return type
annotations on all exports — more typing for much faster builds.
A reference config, annotated
Section titled “A reference config, annotated”{ "compilerOptions": { /* Language and environment */ "target": "ES2022", "lib": ["ES2022"],
/* Modules */ "module": "NodeNext", "moduleResolution": "NodeNext", "resolveJsonModule": true, "verbatimModuleSyntax": true,
/* Emit */ "outDir": "./dist", "rootDir": "./src", "sourceMap": true, "declaration": true, "declarationMap": true,
/* Checking */ "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true,
/* Interop and speed */ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "incremental": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]}Key points
Section titled “Key points”strict: trueis the baseline;strictNullChecksandnoImplicitAnydo most of the work.noUncheckedIndexedAccessandexactOptionalPropertyTypesare the valuable opt-ins beyondstrict.targetsets syntax level and defaultlib;libdeclares types but polyfills nothing.module/moduleResolutionmust match your runtime:NodeNextfor Node,Bundlerfor bundlers.pathsis compile-time only — mirror it in every runtime tool.- Bundlers and
tsxtranspile without checking; runtsc --noEmitseparately in CI. isolatedModulesplusverbatimModuleSyntaxkeeps your code compatible with per-file transpilers.skipLibCheckandincrementalare the cheapest build-time wins;tsc -bwith project references scales monorepos.