Key Compiler Options
TypeScript's compiler is controlled by a tsconfig.json file at the root of your project. Every option in that file shapes what TypeScript accepts, how it resolves modules, and what it emits. Understanding these options — not just copying a template — lets you tune TypeScript to your exact project needs and diagnose mysterious type errors quickly.
The tsconfig.json Structure
A tsconfig.json is a JSON file with a small set of top-level keys. The most important one is compilerOptions, but include, exclude, files, and extends are equally significant:
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {
"@utils/*": ["./src/utils/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}npx tsc --init to generate a starter tsconfig.json with every option documented inline. It is verbose but an excellent reference.strict — The Master Switch
The strict flag is a shorthand that enables a whole family of strictness checks at once. Turning it on is the single most impactful thing you can do to improve type safety:
{
"compilerOptions": {
"strict": true
}
}Setting "strict": true is equivalent to enabling all of these individually:
strictNullChecks — null and undefined are not assignable to other types
strictFunctionTypes — stricter checking of function parameter types
strictBindCallApply — bind/call/apply are type-checked
strictPropertyInitialization — class properties must be initialized
noImplicitAny — variables without a type annotation cannot be inferred as any
noImplicitThis — this used in functions without explicit type is an error
alwaysStrict — emit "use strict" and parse all files in strict mode
useUnknownInCatchVariables — catch clause variables are typed unknown, not any
strict: true on new projects from day one. Retrofitting it onto an existing codebase is painful — start strict and stay strict.strictNullChecks
Without strictNullChecks, null and undefined silently flow into every type — the root cause of countless runtime errors. With it on, you must explicitly acknowledge their possibility:
// strictNullChecks: false (dangerous default in older configs)
let name: string = null; // allowed — no error
let len = name.length; // crashes at runtime
// strictNullChecks: true (correct)
let name: string = null; // Error: Type 'null' is not assignable to type 'string'
// You must widen the type to allow null
let name: string | null = null; // fine
// And narrow it before use
if (name !== null) {
console.log(name.length); // safe — TypeScript knows name is string here
}// Common pattern: optional function parameter
function greet(user?: { name: string }) {
// user is 'User | undefined' — must check before use
if (!user) {
console.log('Hello, stranger!');
return;
}
console.log(`Hello, ${user.name}!`);
}
// Nullish coalescing with strict null checks
function getDisplayName(name: string | null | undefined): string {
return name ?? 'Anonymous';
}strictFunctionTypes
This flag enables contravariant checking of function parameter types. Without it, TypeScript allows unsafe function assignments that can cause runtime errors:
type Handler = (event: MouseEvent) => void;
// Without strictFunctionTypes this assignment is allowed — but it's wrong:
// UIEvent is a supertype of MouseEvent, it lacks MouseEvent-specific properties
const handler: Handler = (e: UIEvent) => {
console.log(e.clientX); // UIEvent has no clientX — runtime crash
};
// With strictFunctionTypes: true
// Error: Type '(e: UIEvent) => void' is not assignable to type 'Handler'
// Types of parameters 'e' and 'event' are incompatible.strictFunctionTypes only applies to function types written in function-type syntax (x: T) => U. Methods defined with method syntax in interfaces are still checked bivariantly for historical reasons.noImplicitAny
When TypeScript cannot infer a type and you have not annotated it, the fallback is any — which disables all type checking for that value. noImplicitAny turns that silent fallback into a compile error:
// noImplicitAny: false (permissive)
function add(a, b) { // a and b are implicitly any
return a + b; // no error, no type safety
}
add('hello', 42); // no error — silently returns 'hello42'
// noImplicitAny: true
function add(a, b) { // Error: Parameter 'a' implicitly has an 'any' type
return a + b;
}
// Fix: annotate your parameters
function add(a: number, b: number): number {
return a + b;
}
add('hello', 42); // Error: Argument of type 'string' is not assignable to 'number'// Also catches untyped destructuring and complex inference failures
const config = JSON.parse(rawJson); // config is 'any' — this is intentional
// noImplicitAny does NOT flag explicit any
// But this is flagged:
function process({ data }) { // Error: 'data' has implicit any type
return data;
}
// Fix:
function process({ data }: { data: unknown }) {
return data;
}target — What JavaScript to Emit
The target option controls which ECMAScript version TypeScript compiles down to. It affects both syntax transformations and which built-in APIs TypeScript assumes are available:
{
"compilerOptions": {
"target": "ES2020"
}
}Target | Transforms | Suitable For |
|---|---|---|
ES5 | async/await → callbacks, arrow → function, class → prototype | Legacy browser support (IE11) |
ES2015 (ES6) | async/await → generators, keeps classes/arrows | Modern browsers, Node 12+ |
ES2017 | async/await kept as-is, object spread kept | Node 14+, evergreen browsers |
ES2020 | Optional chaining, nullish coalescing kept | Node 16+, Chrome 80+ |
ES2022 | Class fields, top-level await kept | Node 18+, current browsers |
ESNext | No transforms — emit as-is | Bundler handles transforms (Vite, webpack) |
target does not polyfill missing runtime APIs. If you target ES2020 but run in an environment without Promise.allSettled, your code will still fail. Use a polyfill library or the lib option to control what TypeScript assumes is available.module — Module System
The module option controls what module format TypeScript emits. This must match your runtime or bundler:
// For Node.js (CommonJS)
{ "compilerOptions": { "module": "CommonJS" } }
// For bundlers (Vite, webpack, Rollup)
{ "compilerOptions": { "module": "ESNext" } }
// For Node.js 16+ with ESM
{ "compilerOptions": { "module": "Node16" } }
// For Node.js 22+ with ESM
{ "compilerOptions": { "module": "NodeNext" } }// Source TypeScript
import { readFile } from 'fs/promises';
export function loadConfig(path: string) {
return readFile(path, 'utf-8');
}
// Emitted with module: "CommonJS"
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadConfig = loadConfig;
const promises_1 = require("fs/promises");
function loadConfig(path) {
return (0, promises_1.readFile)(path, 'utf-8');
}
// Emitted with module: "ESNext"
import { readFile } from 'fs/promises';
export function loadConfig(path) {
return readFile(path, 'utf-8');
}"module": "ESNext" and "moduleResolution": "bundler". The bundler handles module resolution; TypeScript just type-checks.moduleResolution — How Imports Are Resolved
moduleResolution controls how TypeScript finds the file behind an import path. Getting this wrong causes "Cannot find module" errors even when the file exists:
Value | Algorithm | Use When |
|---|---|---|
node | Node.js CommonJS resolution | Legacy Node.js projects (pre-2022) |
node16 | Node.js ESM + CJS resolution | Node.js 16+ with "type": "module" |
nodenext | Latest Node.js resolution | Node.js 22+ projects |
bundler | Bundler-friendly resolution | Vite, webpack, Next.js, Parcel projects |
classic | Old TypeScript algorithm | Avoid — only for very old codebases |
// For a Vite/Next.js frontend
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
}
}
// For a Node.js 18+ backend with ESM
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "node16"
}
}moduleResolution: "bundler", you can omit file extensions in imports (the bundler resolves them). With node16 / nodenext, relative imports to TypeScript files require the .js extension — TypeScript resolves .js imports to .ts source files.lib — Available Built-in APIs
The lib option controls which built-in type definitions TypeScript includes. If you use a browser API that TypeScript says doesn't exist, lib is usually why:
// Browser app — include DOM + modern ECMAScript APIs
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"]
}
}
// Node.js backend — no DOM, only ECMAScript
{
"compilerOptions": {
"lib": ["ES2022"]
}
}
// Service worker / web worker
{
"compilerOptions": {
"lib": ["WebWorker", "ES2020"]
}
}// Without "DOM" in lib:
document.getElementById('app'); // Error: Cannot find name 'document'
fetch('/api/data'); // Error: Cannot find name 'fetch'
// Without "ES2022" in lib:
const arr = [1, 2, 3];
arr.at(-1); // Error: Property 'at' does not exist on type 'number[]'
// With correct lib settings — all of these type-check fineoutDir and rootDir
These two options control the input/output directory structure of your compiled project:
{
"compilerOptions": {
"rootDir": "./src", // TypeScript source lives here
"outDir": "./dist" // Compiled JavaScript goes here
}
}# With rootDir: "./src" and outDir: "./dist"
# Source structure:
src/
index.ts
utils/
helpers.ts
models/
user.ts
# Compiled output mirrors the source structure:
dist/
index.js
utils/
helpers.js
models/
user.jsrootDir, TypeScript uses the longest common path of all input files as the root. This can cause unexpected output directory structures. Always set rootDir explicitly.declaration and declarationMap
When publishing a library, consumers need type information. The declaration flag generates .d.ts files alongside your compiled JavaScript. declarationMap adds a source map so editors can jump to the original .ts source instead of the .d.ts file:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}// src/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export interface Vector2D {
x: number;
y: number;
}// dist/math.d.ts (generated with declaration: true)
export declare function add(a: number, b: number): number;
export interface Vector2D {
x: number;
y: number;
}declaration: true and declarationMap: true for any package you publish to npm. Without them, consumers get no type checking and no "Go to Definition" support.sourceMap
Source maps link the compiled JavaScript back to the original TypeScript source. Without them, stack traces and debugger breakpoints point to the unreadable compiled output:
{
"compilerOptions": {
"sourceMap": true, // generates .js.map files alongside .js
"inlineSourceMap": false // alternative: embed map directly in .js (not for prod)
}
}# With sourceMap: true: dist/ index.js # compiled JavaScript index.js.map # source map (references original .ts file) # Stack trace without source map: TypeError: Cannot read property 'name' of undefined at dist/index.js:42:18 # Stack trace with source map (Node.js uses --enable-source-maps flag): TypeError: Cannot read property 'name' of undefined at processUser (src/index.ts:15:22)
paths — Module Aliases
The paths option lets you create import aliases, mapping short paths to deep directory trees. This is a must-have in large projects where relative imports like ../../../../utils/logger become unmanageable:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@utils/*": ["./src/utils/*"],
"@components/*": ["./src/components/*"],
"@models/*": ["./src/models/*"],
"@config": ["./src/config/index.ts"]
}
}
}// Without paths aliases — unreadable:
import { Logger } from '../../../utils/logger';
import { UserModel } from '../../models/user';
import { AppConfig } from '../../../../config';
// With paths aliases — clean and refactor-friendly:
import { Logger } from '@utils/logger';
import { UserModel } from '@models/user';
import { AppConfig } from '@config';paths is type-checking only — it does not affect runtime module resolution. Your bundler (Vite, webpack) or runtime (Node.js with --experimental-loader) needs its own alias configuration to actually resolve the paths. For example, Vite uses resolve.alias in vite.config.ts.include and exclude
include and exclude control which files TypeScript processes. Without them, TypeScript includes every .ts file in the directory tree — which usually picks up node_modules and test build artifacts:
{
"include": [
"src/**/*",
"tests/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"coverage"
]
}node_modules is excluded by default even if you do not list it. But if you override exclude, you must add it back explicitly or TypeScript will type-check your entire node_modules directory — which is extremely slow.extends — Shared Base Configs
Large codebases or monorepos benefit from a shared base tsconfig.json that all packages extend. This keeps common settings in one place:
// tsconfig.base.json — shared across all packages
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}// packages/web/tsconfig.json — extends base, adds browser-specific settings
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}// packages/api/tsconfig.json — extends base, adds Node.js-specific settings
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}Real Example: Browser App (React + Vite)
// tsconfig.json for a React + Vite project
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
// JSX
"jsx": "react-jsx",
// Strict type checking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
// Interop
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
// Path aliases
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Build output
"outDir": "./dist",
"rootDir": "./src",
// Source maps for debugging
"sourceMap": true,
// Skip type-checking .d.ts files from packages
"skipLibCheck": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Real Example: Node.js Backend
// tsconfig.json for an Express/Fastify Node.js API
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
// Strict type checking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
// Interop
"esModuleInterop": true,
"resolveJsonModule": true,
// Decorators (for NestJS/TypeORM)
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// Build output
"outDir": "./dist",
"rootDir": "./src",
// Source maps for production debugging
"sourceMap": true,
// Incremental compilation — speeds up tsc
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo",
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}
Real Example: npm Library
// tsconfig.json for a published npm library
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
// Strict type checking
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
// Library output — consumers need types
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
// Required for project references / composite builds
"composite": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
Other Commonly Used Options
Option | Default | What It Does |
|---|---|---|
esModuleInterop | false | Allows default imports from CommonJS modules (import fs from "fs") |
allowSyntheticDefaultImports | false | Allows default imports when module does not have a default export |
skipLibCheck | false | Skip type-checking .d.ts files — speeds up compilation |
forceConsistentCasingInFileNames | false | Disallows imports with different casing than the file system |
resolveJsonModule | false | Allows importing .json files with full type inference |
noUnusedLocals | false | Error on declared but unused local variables |
noUnusedParameters | false | Error on declared but unused function parameters |
noImplicitReturns | false | Error if a function branch does not return a value |
exactOptionalPropertyTypes | false | Disallows assigning undefined to optional properties explicitly |
noUncheckedIndexedAccess | false | Adds undefined to array index and object key access results |
incremental | false | Save compilation state to disk for faster subsequent builds |
composite | false | Required for project references — enables incremental + declarations |
noUnusedLocals and noUnusedParameters
These options catch dead code before it accumulates:
// noUnusedLocals: true
function processData(input: string) {
const trimmed = input.trim(); // used below
const uppercased = input.toUpperCase(); // Error: 'uppercased' is declared but its value is never read
return trimmed;
}
// noUnusedParameters: true
function greet(name: string, title: string) { // Error: 'title' is declared but its value is never read
return `Hello, ${name}!`;
}
// Fix: prefix with _ to intentionally mark a parameter as unused
function greet(name: string, _title: string) { // no error
return `Hello, ${name}!`;
}exactOptionalPropertyTypes
This stricter flag distinguishes between a property that is missing and a property explicitly set to undefined. Most codebases should enable this if they use strict null checks:
// exactOptionalPropertyTypes: false (default)
interface Config {
timeout?: number; // means: timeout is missing OR timeout is undefined
}
const c: Config = { timeout: undefined }; // allowed
// exactOptionalPropertyTypes: true
interface Config {
timeout?: number; // means ONLY: timeout is missing (not present in the object)
}
const c: Config = { timeout: undefined }; // Error: Type 'undefined' is not assignable to type 'number'
// To allow undefined explicitly, you must say so:
interface Config {
timeout?: number | undefined;
}incremental — Faster Rebuilds
The incremental flag saves type-check state to a .tsbuildinfo file. On subsequent builds, TypeScript only re-checks files that changed:
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}# First build — checks everything $ tsc # 8.2s # Second build — only changed files $ tsc # 0.4s # The .tsbuildinfo file is a JSON cache — add it to .gitignore echo ".tsbuildinfo" >> .gitignore
.tsbuildinfo to .gitignore. It is a build cache, not source code. Each developer and CI environment should generate their own.jsx — React and Other JSX Frameworks
For React projects, the jsx option controls how JSX is transformed:
Value | Output | Use When |
|---|---|---|
react | React.createElement() calls | React 16 and older, or classic runtime |
react-jsx | Imports from react/jsx-runtime | React 17+ (new JSX transform) |
react-jsxdev | Imports from react/jsx-dev-runtime | React 17+ development mode |
preserve | Keep JSX as-is in .jsx output | Let bundler (Babel/esbuild) handle JSX |
react-native | Keep JSX as-is in .js output | React Native with Metro bundler |
// Modern React project (React 17+)
{
"compilerOptions": {
"jsx": "react-jsx"
}
}
// With react-jsx you do NOT need to import React in every file:
// import React from 'react'; // no longer needed
Quick Reference Summary
Scenario | Recommended Settings |
|---|---|
React + Vite | target: ES2020, module: ESNext, moduleResolution: bundler, jsx: react-jsx |
Next.js | Managed by Next.js — extend next/typescript in tsconfig |
Node.js (CJS) | target: ES2022, module: CommonJS, moduleResolution: node |
Node.js (ESM) | target: ES2022, module: Node16, moduleResolution: node16 |
npm library | composite: true, declaration: true, declarationMap: true |
All projects | strict: true, skipLibCheck: true, forceConsistentCasingInFileNames: true |
strict: true, match module and moduleResolution to your runtime or bundler, use extends for shared configs across packages, and enable declaration plus declarationMap for any library you publish.