Phase 7 · Advanced and tooling · Lesson 7.4
AdvancedThe 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:
| Flag | Catches |
|---|---|
noImplicitAny | Parameters and values whose type can't be inferred silently becoming any |
strictNullChecks | Using a value that may be null / undefined |
strictFunctionTypes | Assigning a function that accepts a narrower parameter (contravariance) |
strictBindCallApply | Wrong arguments to .call, .apply, .bind |
strictPropertyInitialization | Class fields never assigned in the constructor |
noImplicitThis | this with an implicit any type |
useUnknownInCatchVariables | Treating catch (e) as any instead of unknown |
alwaysStrict | Emitting "use strict" and parsing every file in strict mode |
strictBuiltinIteratorReturn | Treating 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) => numberstrictPropertyInitialization
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 explicitlyIt 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 visiblenoImplicitReturns: every code path must return (if any path returns a value).noFallthroughCasesInSwitch: a non-emptycasemustbreak,returnorthrow. Empty cases grouped together are fine.noImplicitOverride: overriding a base method needsoverride, 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" } }nodenext | bundler | |
|---|---|---|
| For | Code that Node runs directly, and libraries | Apps built by a bundler (Vite, esbuild, webpack) |
| Relative imports | Must include the extension in ESM (./util.js) | Extensionless allowed (./util) |
| ESM vs CJS | Decided per file by .mts/.cts and package.json "type" | Not modelled |
package.json "exports" | Respected | Respected |
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 withoutexport type(the transpiler can't tell if it's a value) or using an ambientconst enumfrom another file.verbatimModuleSyntax(TypeScript 5.0): the simpler, stricter rule. Imports and exports without thetypemodifier 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' erasedIt 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"
}
}declarationemits.d.tsfiles: the public types of a library. UseemitDeclarationOnlywhen a bundler produces the JavaScript.declarationMapemits.d.ts.map, so "Go to definition" in a consumer jumps to your.tssource instead of the.d.ts. Essential in monorepos.sourceMapemits.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 forcesdeclarationon, requires all files to be matched byinclude/files, and enables incremental builds. - Dependents type-check against the referenced project's emitted
.d.tsfiles, 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. Plaintscdoes 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)builtskipLibCheck: 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.tsfiles 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 toany.
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:
- Prefer
interface extendsover big intersections. Interface relationships are cached by name; an intersection likeA & B & Cis recomputed and flattened every time it's compared, and conflicting members produceneverinstead of a clear error. - 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."
- Annotate return types of exported functions, especially ones returning complex inferred types. It saves re-inference and makes
.d.tsoutput small. - Name complex types with aliases or interfaces instead of repeating them inline, so they can be cached and shown compactly.
- Tighten
include/exclude, useincremental, 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
strictis 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. nodenextfor Node and libraries (explicit.jsextensions),bundlerfor bundled apps; avoid legacynode10.isolatedModules/verbatimModuleSyntaxkeep code safe for single-file transpilers; the latter requiresimport typefor types.declaration,declarationMapandsourceMapcontrol.d.ts, go-to-source and debugging output.composite+references+tsc -bgive incremental multi-project builds;skipLibChecktrades.d.tschecking for speed.- Diagnose slowness with
--extendedDiagnostics,--generateTrace,--traceResolutionbefore changing code.
Interview cards
1 / 8