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.
- 1strict: the baseline
- 2Two flags that strict leaves out
- 3Module flags: say what you mean
- 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:
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:
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.
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:
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'; // finemoduleResolution 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:
{
"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
strictis a family of checks (null checks, implicit any, function parameter checks and more), and it is the default from TypeScript 6.0 on.noUncheckedIndexedAccessaddsundefinedto array and record lookups, which catches the most common crash thatstrictmisses.exactOptionalPropertyTypesseparates a missing property from one explicitly set toundefined, which matters for PATCH bodies and spreads.verbatimModuleSyntaxmakes the emitted imports match what you wrote, so type-only imports must sayimport type.- Use
moduleResolution: "bundler"for apps built by Vite or another bundler, andnodenextfor code that Node runs directly.