Skip to content
System nominalUTC --:--:--Articles 041
MODULE 05 Lesson 15 / 18 40 MIN

TypeScript

tsconfig, Strictness, and Migrating a Codebase

What each strict flag catches, why the indexed access and optional property checks are separate, and a migration order that keeps the work reviewable.

You rename one file, add a tsconfig.json, run tsc, and get four hundred errors from a module that worked in production this morning. The instinct is to add "strict": false and deal with it later. That gets you a compile that passes and a compiler with nothing to say, which is worse than not having one.

A better order starts with knowing which flag produces which error. This lesson goes flag by flag, then gives the migration sequence.

Everything below ran on TypeScript 6.0.3 with Node 24.11.1. The defaults changed: in TypeScript 5.x, a project with no strict setting turned every strict check off. In TypeScript 6, strict defaults to true, and strictNullChecks with it. Check that before debugging any build.

# TypeScript 5.9.3, no strict setting
$ tsc --noEmit --target es2022 snc.ts
(no output)

# TypeScript 6.0.3, no strict setting, same file
$ tsc --noEmit --target es2022 snc.ts
snc.ts(1,7): error TS2322: Type 'undefined' is not assignable to type 'string'.

The file in both runs is one line, const s: string = undefined;. Opting out is still possible, with "strict": false in the config or --strict false on the command line. You have to ask for it now.

strict is a bundle, and each member catches a different mistake

strict is not one check. It is a group, and every member can be set on its own, which is what makes a staged migration possible.

FlagWhat it catchesDefault
strictNullChecks Reading or assigning null and undefined where a value is required. on with strict
noImplicitAny Parameters and variables whose type the compiler had to guess as any. on with strict
strictFunctionTypes A callback whose parameter type is narrower than the one the receiver will pass. on with strict
strictBindCallApply bind, call and apply invoked with arguments that do not match the function. on with strict
noImplicitThis this whose type is implicitly any. on with strict
useUnknownInCatchVariables Reading properties off a caught error without checking what it is. on with strict
alwaysStrict Nothing at type level. It emits "use strict" in the output. on with strict
noUncheckedIndexedAccess Indexing an array or an index-signature object as though a value is always there. off, not part of strict
exactOptionalPropertyTypes Writing an explicit undefined into an optional property. off, not part of strict

Two of them account for most of the migration errors, and both surprise people who expect strict to mean everything.

strictNullChecks turns absence into a type

Before this flag, undefined was assignable to every type, so a function that could return nothing could claim to return a string. Three errors from a small file show what it catches:

type User = { id: string; email?: string };

function label(user: User): string {
  return user.email.toLowerCase();
}

function maybeLength(value?: string): number {
  return value.length;
}

function readConfig(path: string): string {
  try {
    return JSON.parse(path).name;
  } catch (error) {
    return error.message;
  }
}
error TS18048: 'user.email' is possibly 'undefined'.
error TS18048: 'value' is possibly 'undefined'.
error TS18046: 'error' is of type 'unknown'.

The first two are strictNullChecks. The third is a different member of the family: useUnknownInCatchVariables types a caught error as unknown rather than any, so error.message is rejected until you narrow it. That default converts every catch block in a migrated codebase into an edit, and the fix is a check rather than a cast:

function readConfig(path: string): string {
  try {
    return JSON.parse(path).name;
  } catch (error) {
    if (error instanceof Error) return error.message;
    return String(error);
  }
}

strictFunctionTypes produces the most confusing messages, because the error appears at the assignment and names the parameter that does not fit. It applies to function types written as properties, and it means a callback cannot demand more than the caller promises to pass. Methods are exempt, which is a real inconsistency in the type system and the reason the same callback is an error in one position and accepted in another:

type Handler = (event: { type: string }) => void;

function subscribe(handler: Handler): void {
  handler({ type: "click" });
}

const mouseHandler = (event: { type: string; x: number; y: number }) => {
  console.log(event.x, event.y);
};

subscribe(mouseHandler);
error TS2345: Argument of type '(event: { type: string; x: number; y: number; }) => void' is not assignable to parameter of type 'Handler'.
  Types of parameters 'event' and 'event' are incompatible.
    Type '{ type: string; }' is missing the following properties from type '{ type: string; x: number; y: number; }': x, y

That is a real bug: subscribe promises one property, the handler reads two, and at runtime event.x is undefined. Write the callback to take what it is given, or have subscribe declare the full event shape. Turning the flag off restores the bug it exists to find.

noUncheckedIndexedAccess is separate because it is expensive

Indexing is where the compiler's claim and the runtime truth diverge most often. Ask a Record<string, Order> for a key and the compiler says you get an Order; the runtime hands you undefined for any key you did not set.

type Order = { id: string; total: number };

const orders: Order[] = [{ id: "a1", total: 1 }];
const byId: Record<string, Order> = {};

const first: Order = orders[0];
const looked: Order = byId["a1"];
error TS2322: Type 'Order | undefined' is not assignable to type 'Order'.
  Type 'undefined' is not assignable to type 'Order'.
error TS2322: Type 'Order | undefined' is not assignable to type 'Order'.
  Type 'undefined' is not assignable to type 'Order'.

Both lines compile under strict and fail once the flag is added, because it changes what indexing means: orders[0] is Order | undefined, whatever you know about the data. That is correct for an empty array and for a key you have not set.

It is not in strict because it is noisy. Every items[0] in a loop that a human knows is non-empty becomes an error, and existing code indexes in many places where a guard is unnecessary. Enable it on new projects from the first commit, and schedule it separately during a migration. If that is too much at once, noPropertyAccessFromIndexSignature is a smaller flag in the same family: it forces byId["a1"] over byId.a1 for signature keys, which makes the unchecked read visible without changing any type.

exactOptionalPropertyTypes separates missing from undefined

By default, an optional property means you may omit it, and the compiler treats writing undefined as the same thing. That stops being convenient the moment a serializer or database driver treats absent and undefined differently, which is most of them.

type User = { id: string; email?: string };

function withEmail(user: User, email: string | undefined): User {
  user.email = email;
  return user;
}

function blank(id: string): User {
  return { id, email: undefined };
}
error TS2412: Type 'string | undefined' is not assignable to type 'string' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target.
  Type 'undefined' is not assignable to type 'string'.
error TS2375: Type '{ id: string; email: undefined; }' is not assignable to type 'User' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Types of property 'email' are incompatible.
    Type 'undefined' is not assignable to type 'string'.

Both are errors under this flag and neither is an error under strict. If absence and undefined are different states, build the object conditionally and never write the property. If they are the same state to you, write email?: string | undefined and the error goes away because you have described what you accept. Expect this to be the smallest addition, usually a handful of mapping sites.

Module resolution decides which files the compiler even sees

Two settings in tsconfig.json cause more confusion than any strictness flag. module decides what syntax the emitted JavaScript uses, and moduleResolution decides how the compiler looks up an import specifier.

TypeScript 6 picks a default for moduleResolution from module: nodenext when module is nodenext, node16 when module is node16 or node18, and bundler otherwise. That default is usually right, and it is why a project can compile under Node and fail in a bundler, or the reverse. bundler applies your build tool's rules, so extensionless relative imports behave the way they do in the bundler.

The failure is easy to read once you know the shape. A deep import that works in a bundler is rejected under Node resolution with a message naming the file rather than the config: the resolver found the package, then could not find the subpath inside it because of the exports map. When a build behaves differently in two environments, print the resolved configuration before changing anything:

npx tsc --showConfig

That prints the merged result of every extends and command-line flag, which settles arguments about what the compiler is using faster than reading the files.

skipLibCheck hides errors in files you did not write

skipLibCheck skips type checking inside .d.ts declaration files. It defaults to off and most templates turn it on, because thirty dependencies means thirty sets of declarations and checking all of them is slow.

What it hides is real. Put contradictory declarations in a package's types and compile a file that imports it:

# node_modules/legacy-types/index.d.ts
export declare const bad: string;
export declare const bad: number;

$ tsc --noEmit --strict --module commonjs app.ts
node_modules/legacy-types/index.d.ts(1,22): error TS2451: Cannot redeclare block-scoped variable 'bad'.
node_modules/legacy-types/index.d.ts(2,22): error TS2451: Cannot redeclare block-scoped variable 'bad'.

$ tsc --noEmit --strict --skipLibCheck --module commonjs app.ts
(no output)

The same file, the same import, and the errors vanish. That is the bargain: faster builds and no noise from other people's mistakes, in exchange for a class of mismatch the compiler will not report. Leave it on for application code, and turn it off in CI on a clean install when you suspect a dependency's types.

Migrate in this order, and fix errors by category

The order that works keeps each step reviewable, and assumes TypeScript 6, where strict is the default and you opt out to start.

Rename nothing yet; get the compiler into the project. Install typescript as a dev dependency, add a tsconfig.json with allowJs, checkJs: false, noEmit: true and strict: false, then run tsc. Zero errors proves the tool is wired up. Turning on checkJs here is a legitimate alternative: you get errors in JavaScript files with no rename at all, and JSDoc comments are enough to type them. For a large codebase that has to keep shipping, that is often the better first move.

Rename one module at a time and keep the file compiling. A renamed .ts file starts with every parameter implicitly any, which under TypeScript 6 is an error whether or not you asked for strict:

src/cart.ts(3,25): error TS7006: Parameter 'lines' implicitly has an 'any' type.
src/cart.ts(3,32): error TS7006: Parameter 'taxRate' implicitly has an 'any' type.
src/cart.ts(11,27): error TS7006: Parameter 'order' implicitly has an 'any' type.
src/cart.ts(17,27): error TS7006: Parameter 'orders' implicitly has an 'any' type.
src/cart.ts(17,35): error TS7006: Parameter 'id' implicitly has an 'any' type.
src/cart.ts(18,23): error TS7006: Parameter 'order' implicitly has an 'any' type.
src/cart.ts(21,23): error TS7006: Parameter 'orderId' implicitly has an 'any' type.
src/cart.ts(21,32): error TS7006: Parameter 'orders' implicitly has an 'any' type.

If a true first step is needed, set noImplicitAny: false for the duration, which the old advice called allow-implicit-any mode. The errors drop to zero and you are back to a compiler that reports nothing. Skip that step and annotate as you rename: the annotations are the work anyway, and the any parameters hide the mistakes you are migrating to find.

Annotate the data first, the functions second. Most of the implicit-any errors disappear once the things being passed around have types. A handful of named types at the top of the module resolves most of it, and the errors that remain are about behaviour rather than shape: a value that can be absent, a callback that demands too much. The count falls furthest here.

Fix by category, not by file. Once the annotations are in, the remaining errors sort into groups that share a fix. Missing null checks read TS18048. Caught errors read TS18046. Bad callback parameters are TS2345 with a parameter mismatch underneath. Work one category across the whole codebase, because the fix is the same each time and you get faster and more consistent at it. Going file by file spreads the same decision across fifty pull requests.

Then add the flags one at a time, smallest first. strictNullChecks and useUnknownInCatchVariables catch the most for the least argument and come with strict. After that exactOptionalPropertyTypes, then noUncheckedIndexedAccess last and on its own. Each flag gets one commit with the error count in the message. The trap is the final step: enabling everything, watching the build fail in a way nobody can attribute, and reverting the config.

Lab 40 minutes

Migrate this module in five stages and record the error count at each. Save it as cart.js in an ES module project and write the tsconfig.json for stage one yourself.

export function totalOf(lines, taxRate) {
  let total = 0;
  for (const line of lines) {
    total += line.price * line.qty;
  }
  return total * (1 + taxRate);
}

export function summarise(order) {
  const total = totalOf(order.lines, order.taxRate);
  return `${order.id}: ${total.toFixed(2)} ${order.currency}`;
}

export function findOrder(orders, id) {
  return orders.find((order) => order.id === id);
}

export function contactOf(orderId, orders) {
  const order = findOrder(orders, orderId);
  return order.customer.email.toLowerCase();
}

The five stages: allowJs with checkJs: false; checkJs: true with strict: false; strict: true; exactOptionalPropertyTypes: true; noUncheckedIndexedAccess: true. For each stage write down the number of errors and the error codes you see. Then rename the file to .ts and annotate it until the last stage compiles.

Answer in one sentence each: which stage produced the most errors, which errors were real bugs rather than missing annotations, and which single flag you would leave off this codebase.

Show the solution and the reasoning

The stages do not produce the counts people expect. allowJs alone reports nothing, because TypeScript includes the file without checking it, and checkJs with strict: false reports nothing either, because every parameter is implicitly any and an any body is not checked. The errors appear at stage three:

cart.js(1,25): error TS7006: Parameter 'lines' implicitly has an 'any' type.
cart.js(1,32): error TS7006: Parameter 'taxRate' implicitly has an 'any' type.
cart.js(9,27): error TS7006: Parameter 'order' implicitly has an 'any' type.
cart.js(14,27): error TS7006: Parameter 'orders' implicitly has an 'any' type.
cart.js(14,35): error TS7006: Parameter 'id' implicitly has an 'any' type.
cart.js(15,23): error TS7006: Parameter 'order' implicitly has an 'any' type.
cart.js(18,27): error TS7006: Parameter 'orderId' implicitly has an 'any' type.
cart.js(18,36): error TS7006: Parameter 'orders' implicitly has an 'any' type.

That ordering is the argument for annotating while you rename. Stage two tells you the file is fine, and the compiler has been told to stop looking.

Annotated, the module looks like this. The two checks in contactOf are not decoration: findOrder can return nothing, and the email can be missing.

type Line = { price: number; qty: number };

type Order = {
  id: string;
  taxRate: number;
  currency: string;
  lines: Line[];
  customer: { email?: string };
};

export function totalOf(lines: Line[], taxRate: number): number {
  let total = 0;
  for (const line of lines) {
    total += line.price * line.qty;
  }
  return total * (1 + taxRate);
}

export function summarise(order: Order): string {
  const total = totalOf(order.lines, order.taxRate);
  return `${order.id}: ${total.toFixed(2)} ${order.currency}`;
}

export function findOrder(orders: Order[], id: string): Order | undefined {
  return orders.find((order) => order.id === id);
}

export function contactOf(orderId: string, orders: Order[]): string {
  const order = findOrder(orders, orderId);
  if (!order) throw new Error(`no order ${orderId}`);
  const email = order.customer.email;
  if (!email) throw new Error(`order ${orderId} has no email`);
  return email.toLowerCase();
}

export function firstLine(order: Order): Line | undefined {
  return order.lines[0];
}

The two errors are TS18048, one for order and one for order.customer.email, because the possible absence is inherited by the property access. They are real: the original JavaScript returns undefined from findOrder whenever the id is missing, and every caller dereferences the result immediately. Throwing is a choice; returning undefined and letting callers decide is a different one. Neither is leaving the dereference unguarded.

exactOptionalPropertyTypes adds nothing here, because nothing assigns undefined to email. That is the common outcome: the flag costs nothing unless your mapping code writes explicit undefined, and when it does it is telling you that JSON.stringify will drop that property and the database driver may not. noUncheckedIndexedAccess adds one error, on the read side:

error TS2322: Type 'Line | undefined' is not assignable to type 'Line'.
  Type 'undefined' is not assignable to type 'Line'.

That came from a firstLine(order) returning order.lines[0]. In this module the array is never empty, so the guard is noise. In a real codebase the same expression appears in a dozen places and three of them run on an empty cart, which is why the flag goes last: it finds the occasional real bug at the cost of a hundred annotations.

The verdict follows from those counts. Not strictNullChecks, because its two errors were live crashes. Not exactOptionalPropertyTypes, which cost nothing and would have caught an explicit undefined in mapping code. noUncheckedIndexedAccess is the one to postpone on a mature codebase, noted in the migration plan and scheduled as its own pass once the other flags are settled.

Progress

Mark this lesson done and it stays marked in this browser.