Skip to content

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.

Terminal window
npx tsc --init # writes a heavily commented tsconfig.json
npx tsc --showConfig # prints the fully resolved config (after `extends`)

A config has three parts: which files to include, compilerOptions, and optionally references.

tsconfig.json
{
"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 — filters include. It does not exclude files that are imported by included files.
  • files — an explicit list; disables include.

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.

"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

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 runtime
function greet(name: string) { return name.toUpperCase(); }
greet(null);
// strictNullChecks: true
greet(null); // error: Argument of type 'null' is not assignable to parameter of type 'string'
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.

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 Event

The error is correct: h could be called with a plain Event, which has no clientX.

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.

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: true
const items = ['a', 'b'];
const first = items[0]; // string | undefined
first.toUpperCase(); // error: 'first' is possibly 'undefined'
console.log(first?.toUpperCase());

Which JavaScript version to emit. Syntax newer than the target is downlevelled.

{ "target": "ES2022" }
  • ES2022 is a good default for Node 18+ and evergreen browsers (class fields, .at(), top-level await in modules, Error.cause).
  • ESNext emits 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, so document and window are correctly unavailable.
  • Browser code: include DOM and DOM.Iterable.
  • lib describes what types exist; it does not polyfill anything. If you target ES5 but list ES2022, Object.fromEntries type-checks and then crashes on an old runtime.

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
  • NodeNext implements Node’s real rules: .mts/.cts extensions, the type field in package.json, exports maps, and mandatory file extensions in relative ESM imports (import './x.js', even from x.ts).
  • Bundler (TypeScript 5.0+) mirrors what bundlers actually do: extensionless imports, exports map 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.

Fixes importing CommonJS modules from ESM syntax.

// esModuleInterop: false
import * as express from 'express';
const app = express(); // error: This expression is not callable
// esModuleInterop: true
import express from 'express';
const app = express(); // works

It 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.

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": "./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.

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 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": true }
import pkg from './package.json';
pkg.version; // typed from the file's actual contents

For 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.

This distinction explains most tooling confusion. There are two separate jobs:

  1. 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.
  2. 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:

package.json
{
"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.

Terminal window
npx tsc # compile per tsconfig.json
npx tsc --noEmit # check only
npx tsc --watch # incremental rechecks on save
npx tsc -p tsconfig.build.json
npx tsc --diagnostics # timing and memory, for debugging slow builds

tsc 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.

Terminal window
npm install --save-dev tsx
npx tsx src/script.ts
npx tsx watch src/server.ts

tsx 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:

Terminal window
node --experimental-strip-types script.ts # Node 22.6+
node script.ts # Node 23.6+, on by default

Node 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.

Typical arrangement for a frontend project:

tsconfig.json
{
"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.

For multiple packages that depend on each other:

packages/app/tsconfig.json
{
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"references": [{ "path": "../shared" }]
}
Terminal window
npx tsc -b # build mode: builds dependencies in order, incrementally
npx tsc -b --clean # remove outputs
npx tsc -b --watch

composite: 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.

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.

tsconfig.json — Node 20+ service
{
"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"]
}
  • strict: true is the baseline; strictNullChecks and noImplicitAny do most of the work.
  • noUncheckedIndexedAccess and exactOptionalPropertyTypes are the valuable opt-ins beyond strict.
  • target sets syntax level and default lib; lib declares types but polyfills nothing.
  • module/moduleResolution must match your runtime: NodeNext for Node, Bundler for bundlers.
  • paths is compile-time only — mirror it in every runtime tool.
  • Bundlers and tsx transpile without checking; run tsc --noEmit separately in CI.
  • isolatedModules plus verbatimModuleSyntax keeps your code compatible with per-file transpilers.
  • skipLibCheck and incremental are the cheapest build-time wins; tsc -b with project references scales monorepos.