EduFlow

Section 5 · Shipping TypeScript

tsconfig Strictness That Pays Off

19 min4 min readChoose wisely

Lesson at a glance

Choose the compiler flags that make your types trustworthy, from strict and noUncheckedIndexedAccess to verbatimModuleSyntax and moduleResolution bundler, and turn them on in an existing codebase without stopping work.

  1. 1strict: the baseline
  2. 2Two flags that strict leaves out
  3. 3Module flags: say what you mean
  4. 4Turning flags on in an existing codebase

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Every type in this course is only as trustworthy as the compiler settings behind it. The same Order type that catches a missing tracking number under one tsconfig waves a crash through under another. When I joined the migration I later led, the tsconfig had strict: false and the team was proud of its 100% TypeScript coverage. Most of that coverage was checking very little.

This lesson is about choosing flags deliberately, and about turning them on in a real codebase without a six-week freeze.

strict: the baseline

strict is not one check but a family. The ones that matter most day to day:

Flag What it catches
strictNullChecks Using a value that might be null or undefined
noImplicitAny Parameters and variables silently typed any
strictFunctionTypes Unsafe callback parameter types (lesson 1-2)
useUnknownInCatchVariables Treating a caught value as an Error without checking
strictPropertyInitialization Class fields that are never assigned

From TypeScript 6.0, strict is on by default, so a new project gets it without asking. For older projects it is still the single most valuable line in the file. Everything else in this lesson assumes it.

Two flags that strict leaves out

noUncheckedIndexedAccess makes index access honest. With it, lines[0] is Line | undefined and STOCK[sku] is number | undefined:

TypeScript
const first = cart.lines[0];
first.sku;
// Error: 'first' is possibly 'undefined'.

const available: number = STOCK[sku];
// Error: Type 'number | undefined' is not assignable to type 'number'.

“Cannot read properties of undefined” is the most common runtime error in JavaScript, and empty arrays and missing keys are its favourite sources. This flag catches them. The cost is some extra checks where you know an index is valid (inside for (let i = 0; i < xs.length; i++)); for…of loops and array methods like map are unaffected.

exactOptionalPropertyTypes separates “missing” from “present but undefined”. Cartwheel’s PATCH endpoint treats { couponCode: undefined } as “remove the coupon” and {} as “leave it alone”. Without the flag, both have the same type. With it:

TypeScript
type OrderPatch = { couponCode?: string };
const patch: OrderPatch = { couponCode: undefined };
// Error: Type '{ couponCode: undefined; }' is not assignable to type 'OrderPatch'
// with 'exactOptionalPropertyTypes: true'. …

If you really mean “may be explicitly undefined”, say so: couponCode?: string | undefined. This flag is noisier to adopt, because many libraries’ types were written without it, so turn it on after the others.

TypeScript 5.9’s tsc --init now enables both flags by default in the tsconfig it generates, a strong hint about where the ecosystem is heading.

Three stacked layers. The bottom layer, strict, is the baseline of null and any checks. The middle layer adds noUncheckedIndexedAccess, exactOptionalPropertyTypes and noImplicitOverride for stricter correctness. The top layer covers module behaviour: verbatimModuleSyntax, moduleResolution bundler and erasableSyntaxOnly. How modules are emitted and resolved verbatimModuleSyntax · moduleResolution: bundler · erasableSyntaxOnly Correctness beyond strict noUncheckedIndexedAccess · exactOptionalPropertyTypes · noImplicitOverride Baseline strict (default from TypeScript 6.0)
Three layers of a modern tsconfig

Module flags: say what you mean

verbatimModuleSyntax (TypeScript 5.0) makes emitted imports match what you wrote. Any import without a type modifier is kept; imports marked type are erased. That matters because tools such as Vite, esbuild and Node’s built-in type stripping compile one file at a time and cannot look up whether Order is a type:

TypeScript
import { Order } from './orders';
// Error: 'Order' is a type and must be imported using a type-only import
// when 'verbatimModuleSyntax' is enabled.

import { type Order, placeOrder } from './orders'; // fine

moduleResolution tells TypeScript how your imports are found. Match it to the tool that actually resolves them. For an app built by Vite or another bundler, use "bundler" (with "module": "esnext" or "preserve"): it understands package exports and does not require file extensions. For code that Node runs directly, such as a server or a CLI, use "module": "nodenext", which follows Node’s real rules, including .js extensions in relative imports. The old "node" setting (also called node10) predates package exports; it is deprecated in TypeScript 6.0 and removed in 7.0, along with baseUrl and target: "es5".

erasableSyntaxOnly (TypeScript 5.8) rejects the few TypeScript features that generate runtime code: enum, namespaces with values, constructor parameter properties, and the CommonJS-style import x = require(…) and export =. Turn it on if your code runs through Node’s type stripping or you want TypeScript to stay “JavaScript plus types”.

Put together, a tsconfig for a bundled app in 2026 looks like this:

tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "noEmit": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "skipLibCheck": true,
    "jsx": "react-jsx"
  },
  "include": ["src"]
}

noEmit because the bundler produces the JavaScript and tsc only checks. skipLibCheck because checking every .d.ts in node_modules costs time and finds problems you cannot fix.

Turning flags on in an existing codebase

Flip a flag on a big codebase and you might get 2,000 errors. Do not fix them all in one pull request. Measure first (tsc --noEmit | grep -c "error TS"), then pick one of two approaches. Either fix directory by directory using a separate tsconfig that extends the main one and includes only the clean folders, or turn the flag on everywhere and mark the existing errors with // @ts-expect-error plus a ticket reference, so new code is held to the new standard immediately and the old count only goes down.

The exercise is a scenario: three real bugs, and you pick the flags that would have caught them. The playground itself runs with strict and the ES2022 library, which is why some later features need a small declaration to work there, as you will see next.

Key takeaways

  • strict is a family of checks (null checks, implicit any, function parameter checks and more), and it is the default from TypeScript 6.0 on.
  • noUncheckedIndexedAccess adds undefined to array and record lookups, which catches the most common crash that strict misses.
  • exactOptionalPropertyTypes separates a missing property from one explicitly set to undefined, which matters for PATCH bodies and spreads.
  • verbatimModuleSyntax makes the emitted imports match what you wrote, so type-only imports must say import type.
  • Use moduleResolution: "bundler" for apps built by Vite or another bundler, and nodenext for code that Node runs directly.

Further reading

Check your understanding

4 questions

Question 1 of 4

With strict on but noUncheckedIndexedAccess off, what happens here when the cart is empty?

TypeScript
const first = cart.lines[0];
console.log(first.sku);
Question 2 of 4

type Patch = { couponCode?: string }. With exactOptionalPropertyTypes on, which value is rejected?

Question 3 of 4

Your file has import { Order, placeOrder } from './orders' where Order is a type. With verbatimModuleSyntax on, what do you write?

Question 4 of 4

Your Vite app has "moduleResolution": "node" in its tsconfig. What should you change it to, and why?

Practice

Choose wisely

Which flags would have caught these bugs?

Cartwheel’s checkout already compiles with strict: true. These three bugs still reached production last quarter:

  1. Empty cart crash. The order summary read cart.lines[0].sku to show a thumbnail. For an empty cart, the page crashed with Cannot read properties of undefined.
  2. Coupons vanishing. A form sent { couponCode: formValue.coupon } to the PATCH endpoint. formValue.coupon is typed string | undefined (the field is optional); when it was empty, the value was undefined, and the API treats a present-but-undefined couponCode as “remove the coupon”, so customers lost coupons they had applied earlier.
  3. Silent tax bug. PriceCalculator.applyTax() was renamed to addTax() in the base class. The subclass GulfPriceCalculator still defined applyTax(), which now overrode nothing, so Gulf orders were taxed with the default rate.

Select every compiler flag that, added to strict, would have turned at least one of these bugs into a compile error.

Which flags would have caught these bugs?