TypeScriptKey Compiler Options

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:

JSON
{
  "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"]
}
Note
Run 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:

JSON
{
  "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

Tip
Enable 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:

TS
// 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
}

TS
// 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:

TS
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.
Note
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:

TS
// 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'

TS
// 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:

JSON
{
  "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)

Warning
Setting 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:

JSON
// 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" } }

TS
// 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');
}
Tip
For modern projects using a bundler (Vite, Next.js, etc.), use "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

JSON
// For a Vite/Next.js frontend
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

// For a Node.js 18+ backend with ESM
{
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "node16"
  }
}
Note
With 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:

JSON
// 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"]
  }
}

TS
// 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 fine
outDir and rootDir

These two options control the input/output directory structure of your compiled project:

JSON
{
  "compilerOptions": {
    "rootDir": "./src",   // TypeScript source lives here
    "outDir": "./dist"    // Compiled JavaScript goes here
  }
}

Bash
# 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.js
Warning
If you omit rootDir, 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:

JSON
{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

TS
// src/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

export interface Vector2D {
  x: number;
  y: number;
}

TS
// dist/math.d.ts (generated with declaration: true)
export declare function add(a: number, b: number): number;
export interface Vector2D {
  x: number;
  y: number;
}
Tip
Always enable 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:

JSON
{
  "compilerOptions": {
    "sourceMap": true,       // generates .js.map files alongside .js
    "inlineSourceMap": false // alternative: embed map directly in .js (not for prod)
  }
}

Bash
# 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:

JSON
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@utils/*": ["./src/utils/*"],
      "@components/*": ["./src/components/*"],
      "@models/*": ["./src/models/*"],
      "@config": ["./src/config/index.ts"]
    }
  }
}

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';
Warning
TypeScript's 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:

JSON
{
  "include": [
    "src/**/*",
    "tests/**/*"
  ],
  "exclude": [
    "node_modules",
    "dist",
    "**/*.spec.ts",
    "**/*.test.ts",
    "coverage"
  ]
}
Note
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:

JSON
// tsconfig.base.json — shared across all packages
{
  "compilerOptions": {
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

JSON
// 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/**/*"]
}

JSON
// 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)

JSON
// 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

JSON
// 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

JSON
// 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:

TS
// 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:

TS
// 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:

JSON
{
  "compilerOptions": {
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo"
  }
}

Bash
# 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
Tip
Add .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

JSON
// 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

Success
You now know the purpose and tradeoffs of every major TypeScript compiler option. The key takeaways: always enable 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.