Skip to content
TR

Phase 7 · Advanced and tooling · Lesson 7.4

Advanced

The compiler and tsconfig in depth

Every flag in the strict family and what it catches, the extra safety flags worth turning on, module resolution, isolated transpilation, declaration output, project references and how to diagnose a slow build.

25 min

Two codebases can both say "we use TypeScript" and get wildly different protection, because the real contract lives in tsconfig.json. Interviewers know this: "What does strict actually turn on?", "bundler or nodenext?" and "our type-check takes four minutes, where do you start?" are all fair game. This lesson walks the config from safety flags to build performance.

The strict family

"strict": true is shorthand for a set of flags. New flags are sometimes added to it, so upgrading TypeScript can surface new errors in a strict project. You can turn one off individually ("strict": true, "strictPropertyInitialization": false), but the default for new code is all on. As of TypeScript 5.9 the family is:

FlagCatches
noImplicitAnyParameters and values whose type can't be inferred silently becoming any
strictNullChecksUsing a value that may be null / undefined
strictFunctionTypesAssigning a function that accepts a narrower parameter (contravariance)
strictBindCallApplyWrong arguments to .call, .apply, .bind
strictPropertyInitializationClass fields never assigned in the constructor
noImplicitThisthis with an implicit any type
useUnknownInCatchVariablesTreating catch (e) as any instead of unknown
alwaysStrictEmitting "use strict" and parsing every file in strict mode
strictBuiltinIteratorReturnTreating the return value of built-in iterators as any instead of undefined

noImplicitAny and strictNullChecks

The two you met first. One stops any from leaking in silently; the other makes null and undefined separate types.

// @ts-expect-error -- Parameter 'items' implicitly has an 'any' type.
function count(items) {
  return items.length;
}
 
function first(list: string[]): string | undefined {
  return list[0];
}
// @ts-expect-error -- Object is possibly 'undefined'.
first([]).toUpperCase();

strictFunctionTypes

Function-type parameters are checked contravariantly (see the variance lesson). Method-shorthand parameters are still bivariant.

type Handler = (value: string | number) => void;
 
const onlyStrings = (value: string) => value.trim();
 
// @ts-expect-error -- Type '(value: string) => string' is not assignable to type 'Handler'.
const h: Handler = onlyStrings;

strictBindCallApply

Without it, .call, .apply and .bind accept any arguments and return any.

function add(a: number, b: number) {
  return a + b;
}
 
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'number'.
add.call(undefined, 1, "2");
 
// @ts-expect-error -- Argument of type '[number]' is not assignable to parameter of type '[a: number, b: number]'.
add.apply(undefined, [1]);
 
const addOne = add.bind(undefined, 1);
//    ^? const addOne: (b: number) => number

strictPropertyInitialization

A declared field must be assigned in its initializer or the constructor. Requires strictNullChecks.

class User {
  // @ts-expect-error -- Property 'name' has no initializer and is not definitely assigned in the constructor.
  name: string;
  email: string;
  nickname?: string;   // ok: optional
  id!: number;         // ok: definite assignment assertion (you promise it's set)
 
  constructor(email: string) {
    this.email = email;
  }
}

The ! escape hatch is for fields set by something TypeScript can't see (an init() method, a framework). It's a promise, not a check.

noImplicitThis

The classic bug: a nested function has its own this, not the object's.

const counter = {
  count: 0,
  start() {
    setTimeout(function () {
      // @ts-expect-error -- 'this' implicitly has type 'any' because it does not have a type annotation.
      this.count++;
    }, 1000);
  },
};

The fix is an arrow function (which captures the outer this) or an explicit this parameter: function (this: Counter) { ... }.

useUnknownInCatchVariables

Anything can be thrown in JavaScript, not only Errors. With this flag (TypeScript 4.4+), catch variables are unknown and you have to narrow:

function parse(text: string) {
  try {
    return JSON.parse(text);
  } catch (e) {
    // @ts-expect-error -- 'e' is of type 'unknown'.
    console.error(e.message);
 
    if (e instanceof Error) console.error(e.message); // ok after narrowing
  }
}

alwaysStrict and strictBuiltinIteratorReturn

alwaysStrict makes the compiler parse in JavaScript strict mode and emit "use strict" in non-module files. ES modules are strict anyway, so you mostly notice it in scripts.

strictBuiltinIteratorReturn (added to strict in TypeScript 5.6) types the "done" result of built-in iterators as undefined instead of any:

const it = new Set([1, 2]).values();
const next = it.next().value;
//    ^? const next: number | undefined
 
// @ts-expect-error -- 'next' is possibly 'undefined'.
next.toFixed(2);

Without the flag next would be any and the call would compile.

Quick check

Which flag is NOT part of strict?

Beyond strict: extra safety flags

These are not included in strict. Each closes a real gap. Each block below is checked with the flag named in its comment switched on.

noUncheckedIndexedAccess

Index signatures and array indexing claim the value always exists. This flag adds | undefined to indexed reads.

// "noUncheckedIndexedAccess": true
const scores: Record<string, number> = {};
const s = scores["ada"];   // number | undefined (without the flag: number)
s.toFixed();               // error: 's' is possibly 'undefined'
 
const list = [1, 2, 3];
const x = list[5];         // number | undefined
for (const n of list) {}   // n: number (iteration is not affected)

It's the most valuable extra flag, and also the noisiest: every arr[i] needs a check or a !.

exactOptionalPropertyTypes

Normally age?: number means "missing or undefined". With this flag, ? means only "may be missing"; assigning undefined explicitly is an error unless the type says | undefined.

// "exactOptionalPropertyTypes": true
interface Options { timeout?: number }
 
const a: Options = {};                     // ok
const b: Options = { timeout: undefined }; // error: undefined is not assignable to number
 
interface Loose { timeout?: number | undefined } // opt back in explicitly

It matters for code that distinguishes "timeout" in options from options.timeout === undefined, such as object spreads that would overwrite a default with undefined.

The rest of the list

// "noImplicitReturns": true
function sign(n: number) {  // error: Not all code paths return a value.
  if (n > 0) return "positive";
  if (n < 0) return "negative";
}
 
// "noFallthroughCasesInSwitch": true
function f(k: number) {
  switch (k) {
    case 1:                 // error: Fallthrough case in switch.
      console.log("one");
    case 2:
      console.log("two");
  }
}
 
// "noImplicitOverride": true
class Base { save() {} }
class Child extends Base {
  save() {}            // error: must have an 'override' modifier
  override load() {}   // error: 'load' is not declared in the base class
}
 
// "noPropertyAccessFromIndexSignature": true
interface Env { [key: string]: string | undefined; NODE_ENV: string }
declare const env: Env;
env.NODE_ENV;       // ok: declared property
env.API_URL;        // error: must be accessed with ['API_URL']
env["API_URL"];     // ok: bracket makes the dynamic lookup visible
  • noImplicitReturns: every code path must return (if any path returns a value).
  • noFallthroughCasesInSwitch: a non-empty case must break, return or throw. Empty cases grouped together are fine.
  • noImplicitOverride: overriding a base method needs override, so renaming the base method breaks the subclass loudly instead of silently turning the override into a new method.
  • noPropertyAccessFromIndexSignature: dot access is reserved for declared properties, so typos in property names can't hide behind an index signature.

Module settings: bundler vs nodenext

module controls the emitted module format; moduleResolution controls how import "x" is turned into a file. For modern projects there are two sensible choices:

{ "compilerOptions": { "module": "nodenext" } }
{ "compilerOptions": { "module": "esnext", "moduleResolution": "bundler" } }
nodenextbundler
ForCode that Node runs directly, and librariesApps built by a bundler (Vite, esbuild, webpack)
Relative importsMust include the extension in ESM (./util.js)Extensionless allowed (./util)
ESM vs CJSDecided per file by .mts/.cts and package.json "type"Not modelled
package.json "exports"RespectedRespected

The .js extension in nodenext surprises people: you write import { x } from "./util.js" in a .ts file, because the import must be correct for the emitted JavaScript. TypeScript maps it back to util.ts when checking. (TypeScript 5.7 added rewriteRelativeImportExtensions if you prefer writing ./util.ts.)

moduleResolution: "node" (also called node10) is the legacy mode: it ignores package.json "exports" and shouldn't be used for new projects. Libraries should generally be checked with nodenext: code that resolves under nodenext also works in bundlers, not necessarily the other way round.

isolatedModules and verbatimModuleSyntax

Tools like esbuild, SWC and Babel compile one file at a time without the type checker. Some TypeScript code can't be compiled that way, because the output depends on information from other files.

  • isolatedModules: errors on code a single-file transpiler can't handle correctly, such as re-exporting a type without export type (the transpiler can't tell if it's a value) or using an ambient const enum from another file.
  • verbatimModuleSyntax (TypeScript 5.0): the simpler, stricter rule. Imports and exports without the type modifier are kept exactly as written; those with it are removed. So a type-only import must say so.
// "verbatimModuleSyntax": true
import { User } from "./types";       // error: 'User' is a type and must be imported using a type-only import
import type { User } from "./types";  // ok: erased
import { type User, save } from "./db"; // ok: 'save' kept, 'User' erased

It replaced the older importsNotUsedAsValues and preserveValueImports flags. Under nodenext it also forbids ESM import syntax in files that emit CommonJS.

Output: declaration, declarationMap, sourceMap

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist"
  }
}
  • declaration emits .d.ts files: the public types of a library. Use emitDeclarationOnly when a bundler produces the JavaScript.
  • declarationMap emits .d.ts.map, so "Go to definition" in a consumer jumps to your .ts source instead of the .d.ts. Essential in monorepos.
  • sourceMap emits .js.map, so debuggers and stack traces point at TypeScript lines.

isolatedDeclarations (TypeScript 5.5) goes further: it requires explicit types on exports so other tools can generate .d.ts files without running the type checker.

Incremental builds and project references

incremental saves the previous build's state in a .tsbuildinfo file, so the next run only re-checks what changed.

Project references split a large codebase into smaller projects with their own tsconfig.json:

{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "references": [{ "path": "../shared" }]
}
  • A referenced project must set composite: true. That forces declaration on, requires all files to be matched by include/files, and enables incremental builds.
  • Dependents type-check against the referenced project's emitted .d.ts files, not its sources, so each project is checked once.
  • Build with tsc -b (build mode). It builds references in dependency order and skips projects that are up to date. Plain tsc does not build references.
npx tsc -b              # build this project and everything it references
npx tsc -b --watch      # incremental watch across projects
npx tsc -b --clean      # delete outputs of all referenced projects
npx tsc -b --verbose    # explain why each project is (re)built

skipLibCheck: the trade-off

skipLibCheck: true skips type-checking all .d.ts files (both node_modules and your own). It's in nearly every real config.

  • Pro: much faster builds, and it avoids errors from conflicting or badly written third-party declarations you can't fix.
  • Con: real errors inside .d.ts files go unreported, including your own handwritten ones and conflicts between two versions of the same types. Types that reference a missing module can silently degrade to any.

A reasonable rule: turn it on, but keep handwritten declarations in .ts files where possible, and run a check without it occasionally when upgrading dependencies.

Performance: when the type-check is slow

Start by measuring, not guessing:

npx tsc --noEmit --extendedDiagnostics  # time per phase, types created, memory
npx tsc --noEmit --generateTrace trace  # trace for a profiler (analyze with @typescript/analyze-trace)
npx tsc --noEmit --traceResolution      # why each import resolved to which file
npx tsc --listFiles                     # every file in the program

--extendedDiagnostics tells you where the time goes (parse, bind, check), how many files are included (a stray include pulling in node_modules or build output is a common culprit) and how many types were instantiated. --generateTrace shows which expressions are expensive. --traceResolution explains wrong or duplicated module resolution.

Then apply the classic fixes:

  1. Prefer interface extends over big intersections. Interface relationships are cached by name; an intersection like A & B & C is recomputed and flattened every time it's compared, and conflicting members produce never instead of a clear error.
  2. Avoid huge unions. Comparing against a union is roughly linear per member, and combining unions (for example in template literal types) multiplies. TypeScript caps union size at 100,000 members and reports "Expression produces a union type that is too complex to represent."
  3. Annotate return types of exported functions, especially ones returning complex inferred types. It saves re-inference and makes .d.ts output small.
  4. Name complex types with aliases or interfaces instead of repeating them inline, so they can be cached and shown compactly.
  5. Tighten include/exclude, use incremental, and split large codebases with project references.
// Slower and less clear: anonymous intersection, re-evaluated at each use
type SlowUser = { id: string } & { name: string } & { roles: string[] };
 
// Faster: named interfaces with extends, cached and reported by name
interface Entity { id: string }
interface Named { name: string }
interface User extends Entity, Named {
  roles: string[];
}
 
const u: User = { id: "1", name: "Ada", roles: [] };
const same: SlowUser = u; // structurally identical, just cheaper to work with

▶ Try it in the TypeScript Playground

Spot the error

A developer turns on noUncheckedIndexedAccess and "fixes" the new error like this. It compiles. What's the problem?

function initials(names: string[]): string {
  let out = "";
  for (let i = 0; i <= names.length; i++) {
    out += names[i]![0]!.toUpperCase();
  }
  return out;
}
Show the answer

The loop condition is i <= names.length, one past the end. names[i] is undefined on the last iteration and [0] throws. The flag flagged exactly this read, and the ! assertions silenced it. Use iteration that can't go out of bounds, and handle empty strings honestly:

function initials(names: string[]): string {
  let out = "";
  for (const name of names) {
    out += name.charAt(0).toUpperCase(); // charAt returns "" for an empty string
  }
  return out;
}

Treat each ! added for this flag as a question: "am I sure this index exists?"

Quick check

You maintain a library published to npm and consumed by both Node and bundler users. Which setting should you type-check it with?

Recap

  • strict is a bundle of nine flags in TS 5.9; each catches a specific class of bug, and new flags can join it on upgrade.
  • Worth adding: noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitReturns, noFallthroughCasesInSwitch, noImplicitOverride, noPropertyAccessFromIndexSignature.
  • nodenext for Node and libraries (explicit .js extensions), bundler for bundled apps; avoid legacy node10.
  • isolatedModules / verbatimModuleSyntax keep code safe for single-file transpilers; the latter requires import type for types.
  • declaration, declarationMap and sourceMap control .d.ts, go-to-source and debugging output.
  • composite + references + tsc -b give incremental multi-project builds; skipLibCheck trades .d.ts checking for speed.
  • Diagnose slowness with --extendedDiagnostics, --generateTrace, --traceResolution before changing code.

Interview cards

1 / 8