Chapter 16

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

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.


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.

Rate this chapter
4.5  / 5  (15 ratings)

๐Ÿ’ฌ Comments