tsconfig.json Complete Reference: strict Options and Monorepo
tsconfig.json Is Not Optional
The typical team approach: copy someone else's tsconfig.json, then disable options whenever errors appear. This leaves security holes and you have no idea what protection you've lost.
This chapter starts with the 8 sub-options that strict expands into, explains what each one catches, then covers module systems, path aliases, Monorepo project references, and incremental compilation. It ends with recommended base configs for three scenarios.
strict: true Expands to 8 Sub-Options
Writing "strict": true in tsconfig is equivalent to enabling the following 8 options simultaneously. Each can be controlled individuallyโstrict: true is just shorthand.
1. strictNullChecks
When off, null and undefined can be assigned to any type. This is the root cause of most null pointer crashes.
// strictNullChecks: false (default off)
function getLength(s: string): number {
return s.length; // s can be null, runtime error
}
getLength(null); // compiles fine, runtime TypeError
// strictNullChecks: true
function getLength(s: string): number {
return s.length;
}
getLength(null); // Error: Argument of type 'null' is not assignable to 'string'
// Correct handling
function getLength(s: string | null): number {
return s?.length ?? 0;
}
This is the highest return-on-investment option among the 8. Every possible null or undefined must be explicitly annotated and handled.
2. strictFunctionTypes
Enforces contravariance for function parameter types, blocking a class of silent type unsafety.
// Passes with strictFunctionTypes: false
type Logger = (msg: string | number) => void;
const log: Logger = (msg: string) => console.log(msg.toUpperCase());
// Calling with a number causes runtime crash on .toUpperCase()
// strictFunctionTypes: true
type Logger = (msg: string | number) => void;
const log: Logger = (msg: string) => console.log(msg.toUpperCase());
// Error: Type '(msg: string) => void' is not assignable to type 'Logger'
// string is not assignable to string | number in contravariant position
// Correct version
const log: Logger = (msg: string | number) => console.log(String(msg).toUpperCase());
Note: this only affects function type syntax (type F = (x: T) => U), not method declaration syntax ({ method(x: T): U }). Methods remain bivariant for historical reasons.
3. strictBindCallApply
Gives bind, call, and apply proper type checking instead of degrading to any.
function add(a: number, b: number): number {
return a + b;
}
// strictBindCallApply: false
const result = add.call(null, "hello", "world"); // compiles, returns any
// strictBindCallApply: true
const result = add.call(null, "hello", "world");
// Error: Argument of type 'string' is not assignable to parameter of type 'number'
const result2 = add.call(null, 1, 2); // correct, result2: number
4. strictPropertyInitialization
Class properties must be initialized in the constructor, or marked optional or given a default value.
// Error: properties not initialized
class User {
name: string; // Error: Property 'name' has no initializer
age: number; // Error
constructor() {
// forgot to initialize
}
}
// Correct approach 1: initialize in constructor
class User {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
// Correct approach 2: definite assignment assertion (use sparingly)
class UserWithInit {
name!: string; // ! means "I guarantee initialization elsewhere"
init(name: string) {
this.name = name;
}
}
The ! assertion is an escape hatch, not a normal pattern. Heavy use of ! on class properties indicates the class design needs rethinking.
5. noImplicitAny
Prevents TypeScript from silently inferring any when it cannot determine a type.
// noImplicitAny: false โ passes silently
function process(data) { // data implicitly inferred as any
return data.value * 2; // no type protection at all
}
// noImplicitAny: true
function process(data) { // Error: Parameter 'data' implicitly has an 'any' type
return data.value * 2;
}
// Must annotate explicitly
function process(data: { value: number }): number {
return data.value * 2;
}
// Use unknown when the type is genuinely unknown
function process(data: unknown): number {
if (typeof data === "object" && data !== null && "value" in data) {
return (data as { value: number }).value * 2;
}
throw new Error("Invalid data");
}
6. noImplicitThis
The type of this inside functions must be explicit, not implicitly any.
// Problem: this type unknown
const counter = {
count: 0,
increment: function() {
this.count++; // Error with noImplicitThis: 'this' implicitly has type 'any'
}
};
// Solution 1: method shorthand (this type inferred correctly)
const counter = {
count: 0,
increment() {
this.count++; // this correctly inferred as the object type
}
};
// Solution 2: explicit this parameter (TypeScript-specific syntax)
interface Counter {
count: number;
}
function increment(this: Counter): void {
this.count++;
}
7. alwaysStrict
Inserts "use strict"; at the top of every emitted JS file and parses source files in strict mode.
// With alwaysStrict, emitted JS looks like:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
// ...
This does not affect type checking, but ensures runtime strict mode (prevents assigning to undeclared variables, etc.).
8. useUnknownInCatchVariables
Changes the catch variable type from any to unknown, forcing a type check before use.
// useUnknownInCatchVariables: false (old behavior)
try {
fetchData();
} catch (err) {
console.log(err.message); // err is any โ no protection
}
// useUnknownInCatchVariables: true
try {
fetchData();
} catch (err) {
// err is unknown
console.log(err.message); // Error: Object is of type 'unknown'
// Correct handling
if (err instanceof Error) {
console.log(err.message); // safe
} else {
console.log(String(err));
}
}
target vs lib: Code That Runs vs APIs That Exist
These two options are frequently confused, but they control entirely different things.
target controls what JavaScript version TypeScript compiles your code into.
lib controls what browser/runtime APIs TypeScript believes exist.
{
"compilerOptions": {
"target": "ES2017",
"lib": ["ES2022", "DOM"]
}
}
What this config means:
target: ES2017: downcompile arrow functions, async/await to ES2017 syntax (async/await becomes Promise chains)lib: ES2022: allow usingArray.prototype.at(),Object.hasOwn(), etc. (your bundler or polyfills provide the runtime implementation)lib: DOM: allow usingdocument,window,fetch, and other browser APIs
// target: ES2017, lib: ES2022
const arr = [1, 2, 3];
const last = arr.at(-1); // compiles (lib allows it), but needs polyfill in old browsers
// Without DOM lib:
const el = document.getElementById("app"); // Error: Cannot find name 'document'
Common trap: lowering target (e.g., to ES5) does not replace polyfills. Target handles syntax only, not new APIs.
module + moduleResolution Combination Matrix
These two options must be paired correctly. Wrong combinations cause module resolution failures.
module |
moduleResolution |
Use case |
|---|---|---|
CommonJS |
node |
Traditional Node.js projects |
ESNext |
bundler |
Vite / Webpack projects (recommended) |
Node16 |
Node16 |
Native ESM Node.js (.mjs / exports field) |
Nodenext |
nodenext |
Same as above, more Node.js features |
ESNext |
node |
Not recommended โ resolution inconsistencies |
Scenario 1: Vite / Webpack frontend project
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2020"
}
}
bundler mode does not require .js extensions, matching bundler behavior.
Scenario 2: Native Node.js ESM ("type": "module" in package.json)
{
"compilerOptions": {
"module": "Node16",
"moduleResolution": "Node16",
"target": "ES2022"
}
}
Node16 mode requires .js extensions in import statements:
// Must write .js even though the file is actually .ts
import { helper } from "./utils.js";
paths Aliases
Eliminate ../../../../utils-style relative paths.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
Usage:
// No more ../../components/Button
import { Button } from "@components/Button";
import { formatDate } from "@utils/date";
Important: paths only tells TypeScript where types are โ it does not affect runtime. You also need to configure your bundler to recognize the same aliases:
// vite.config.ts
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@components": path.resolve(__dirname, "./src/components"),
}
}
});
Configuring only paths without configuring the bundler: TypeScript reports no errors, but you get Cannot find module at runtime.
Project References: The Correct Monorepo Approach
When a Monorepo contains multiple packages (packages/core, packages/api, packages/web), compiling everything under one tsconfig means every small change triggers a full project re-check.
Project references split compilation into independent units that only rebuild changed parts.
Step 1: Each package has its own tsconfig.json with composite: true
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
composite: true requirements:
- Must have
declaration: true(generates.d.ts) - Must specify
rootDir - Must explicitly specify source files via
includeorfiles
Step 2: Dependent packages point to dependencies via references
// packages/api/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../core" }
],
"include": ["src"]
}
Step 3: Root tsconfig aggregates all packages
// tsconfig.json (project root)
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
Build commands:
# Build all packages in dependency order
npx tsc --build
# Build a specific package and its dependencies
npx tsc --build packages/api
# Clear build cache
npx tsc --build --clean
Project references force api to consume core's public types (via .d.ts) rather than accessing core's source files directly โ this enforces module boundaries.
include / exclude / files
These three options control which files TypeScript processes. Priority from high to low: files > include > exclude.
{
"include": ["src/**/*"],
"exclude": [
"node_modules",
"**/*.test.ts",
"dist"
],
"files": [
"src/global.d.ts"
]
}
When to use files: Only a few files need explicit inclusion, or you want precise control over compilation entry points. Most projects need only include.
Common misconception: exclude does not mean "don't check". If an excluded file is imported by a file within include, TypeScript still processes it. exclude only means "don't scan this file as an independent entry point".
Incremental Compilation: incremental and tsBuildInfoFile
For large projects, re-checking all files on every tsc is too slow. incremental saves the previous compilation result and only rechecks changed files on the next run.
{
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
}
}
Add .tsbuildinfo to .gitignore (it's a local cache, not for source control):
# .gitignore
.tsbuildinfo
*.tsbuildinfo
In CI, save .tsbuildinfo as a cache artifact to benefit from incremental builds across runs.
Recommended Base Configs for Three Scenarios
Node.js Application
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
React Application (Vite)
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Library (Publishing to npm)
{
"compilerOptions": {
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2018"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"composite": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Anti-Patterns
Anti-pattern 1: Using skipLibCheck: true as a universal fix
// Just masks the real problem
{ "skipLibCheck": true }
// When it's reasonable: third-party library type declarations have bugs you can't fix
// When it's not: using it to suppress errors from your own code
Anti-pattern 2: "strict": false with a handful of individual options
// Worse than just using "strict": true and disabling what you don't want
{
"strictNullChecks": true,
"noImplicitAny": true,
// missed strictFunctionTypes โ security hole remains
}
The correct approach: strict: true enables everything, then disable individual options with a documented reason.
Anti-pattern 3: paths configured but fails at runtime
Configuring tsconfig paths without configuring the bundler or ts-node path mapping: type checking passes, runtime throws Cannot find module.
Summary Table
| Option | Purpose | Default | Recommended |
|---|---|---|---|
strictNullChecks |
null/undefined safety | false | true |
strictFunctionTypes |
function parameter contravariance | false | true |
strictBindCallApply |
typed bind/call/apply | false | true |
strictPropertyInitialization |
class property init | false | true |
noImplicitAny |
ban implicit any | false | true |
noImplicitThis |
typed this | false | true |
alwaysStrict |
emit use strict | false | true |
useUnknownInCatchVariables |
catch as unknown | false | true |
target |
output syntax version | ES3 | ES2020+ |
module |
module format | CommonJS | Node16 / ESNext |
moduleResolution |
module resolution strategy | node | Node16 / bundler |
incremental |
incremental compilation | false | true (large projects) |
composite |
project reference support | false | true (Monorepo) |
Next Chapter
Chapter 17 covers declaration files (.d.ts): when a JavaScript library has no TypeScript types, how to write type declarations by hand, and how to publish them to DefinitelyTyped (@types/xxx). This is the mechanism that makes the entire JS ecosystem TypeScript-friendly.