Skip to main content
noUncheckedIndexedAccess and exactOptionalPropertyTypes: How We Turn On TypeScript's Strict-Plus Flags in Real Codebases

noUncheckedIndexedAccess and exactOptionalPropertyTypes: How We Turn On TypeScript's Strict-Plus Flags in Real Codebases

October 30, 2026
Frontend Engineering
8 min read

"strict": true does not make TypeScript check array and record reads, or explicit undefined in optional properties. Two extra compiler flags, noUncheckedIndexedAccess and exactOptionalPropertyTypes, close both gaps, and a per-file error baseline in CI lets an existing codebase adopt them gradually instead of in one giant pull request.

Our team turned both flags on in existing React and Node.js codebases. This post covers what each flag catches, the fix patterns we settled on, and the ratchet script that lets us adopt them file by file. Every compiler behaviour described here was checked against TypeScript 5.9.3.

Key takeaways

• noUncheckedIndexedAccess (TypeScript 4.1+) types every array index and index-signature read as T | undefined, so rows[0].id becomes a compile error until the missing case is handled.

• exactOptionalPropertyTypes (TypeScript 4.4+) stops an optional property declared as prop?: T from accepting an explicit undefined, which catches an object spread like { ...defaults, ...overrides } overwriting a default with undefined.

• Neither flag is part of "strict": true, but since TypeScript 5.9 the tsconfig.json generated by tsc --init enables both.

• We fix index reads with narrowing, for...of loops and destructuring checks, not with the ! non-null assertion, which brings back the exact crash the flag exists to catch.

• We adopt both flags through a second tsconfig.strict.json and a per-file error baseline that fails CI only when a file gets worse.

What does "strict": true leave out?

"strict": true is an umbrella option that enables a fixed family of checks: strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables, alwaysStrict and strictBuiltinIteratorReturn. noUncheckedIndexedAccess and exactOptionalPropertyTypes are not in that family, because turning them on breaks a lot of existing code.

Under plain strict mode, TypeScript accepts const firstId: string = rows[0].id even though rows can be empty, and accepts const max: number = limits['uploads'] on a Record<string, number> even though the key may not exist. Both lines compile, and both can produce undefined at runtime.

TypeScript itself now nudges new projects toward stricter settings. Since TypeScript 5.9, the tsconfig.json that tsc --init generates sets noUncheckedIndexedAccess and exactOptionalPropertyTypes to true next to strict. Codebases that started earlier rarely have them unless someone added them on purpose.

What does noUncheckedIndexedAccess actually catch?

noUncheckedIndexedAccess adds undefined to the type of every read through an array index or an index signature such as Record<string, T>. With the flag on, rows[0] is typed User | undefined and limits['uploads'] is typed number | undefined. Properties declared by name, tuple positions inside a known length, and values produced by for...of keep their plain types.

Two behaviours surprise most teams. A length check does not narrow an index read: inside if (rows.length > 0), rows[0] is still User | undefined. Array destructuring is covered too, so const [first] = rows gives first the type User | undefined.

Our fix patterns: read the element once into a variable and narrow that variable with an if check; iterate with for...of or rows.entries() instead of indexing; and make lookups that can miss look like they can miss, by checking the handler from handlers[event.type] before calling it. For runtime lookup tables we often switch Record<string, T> to Map<string, T>, because Map.get already returns T | undefined. A Record keyed by a fixed union such as Record<'free' | 'pro', number> is not an index signature, so its reads stay non-optional.

We also enable a lint rule against non-null assertions, @typescript-eslint/no-non-null-assertion or noNonNullAssertion in Biome, because rows[0]!.id silences the flag and keeps the crash.

What bug does exactOptionalPropertyTypes prevent?

exactOptionalPropertyTypes makes prop?: T mean the property is either absent or holds a T, and stops it from accepting an explicit undefined. Without the flag, TypeScript treats "missing" and "present but undefined" as the same thing. JavaScript does not: an explicit undefined in an object spread overwrites the value before it, and the in operator reports the key as present.

The classic bug is a defaults merge. A function withDefaults(overrides) takes a partial Options object, typed with the Partial utility type, and returns { ...defaults, ...overrides }. Calling withDefaults({ timeoutMs: undefined }) compiles under plain strict mode, and the return type promises timeoutMs: number, but the real value is undefined, which setTimeout treats as an immediate timeout. With exactOptionalPropertyTypes on, that call fails with error TS2379.

The same distinction matters for PATCH endpoints, where an absent field means "leave it alone" and an explicit value means "change it".

The main friction is React props and values that are already T | undefined. Passing hint={maybeHint} to a component whose props declare hint?: string fails with error TS2375. For pass-through props we declare hint?: string | undefined, which is exactly what @types/react does for DOM attributes such as className. For config objects we only add the key when a value exists, with a conditional spread: ...(timeoutMs !== undefined && { timeoutMs }). skipLibCheck does not help with third-party types, because the error is reported at the call site.

Which other strict-plus flags are worth turning on?

• noUncheckedIndexedAccess (TypeScript 4.1): catches array and index-signature reads that can miss. High adoption cost in data-heavy code, mostly mechanical. We turn it on.

• exactOptionalPropertyTypes (TypeScript 4.4): catches explicit undefined in optional properties. Medium cost, with friction around third-party types. We turn it on.

• noImplicitOverride (TypeScript 4.3): requires the override keyword on overriding members, so a base-class rename cannot silently orphan a subclass method. Low cost. We turn it on wherever classes exist, such as NestJS services.

• noFallthroughCasesInSwitch (TypeScript 1.8): catches non-empty case blocks without break or return. Low cost. We turn it on.

• noPropertyAccessFromIndexSignature (TypeScript 4.2): forces bracket access such as process.env['KEY'] for index-signature keys. Low cost but noisy, and noUncheckedIndexedAccess already types those reads as possibly undefined, so we leave it off.

How do we adopt these flags in a large codebase without one giant PR?

Compiler flags apply to the whole program, so there is no per-file switch for noUncheckedIndexedAccess. We keep the main tsconfig.json unchanged, so editors and production builds stay green, and add a tsconfig.strict.json that extends it and enables the two flags. Only CI type-checks against that second config.

A small Node.js script runs tsc -p tsconfig.strict.json --noEmit --pretty false, parses each line of the form file(line,col): error TSxxxx, counts errors per file, and compares the counts with a committed ts-strict-baseline.json. CI fails only when a file has more errors than its baseline entry. New files must be clean from day one, and old files are fixed at whatever pace the team can afford.

Running the script with an --update flag rewrites the baseline, and any pull request that removes errors updates it, so the numbers only go down. When the baseline is empty, the two flags move into the main tsconfig.json and the script is deleted. We avoid the common alternative of mass-inserting // @ts-expect-error comments, because each one hides every error on the line below it and those comments rarely get cleaned up.

Why do these flags matter more with AI-generated code?

AI coding assistants write the happy path fluently, and code such as results[0].message or config[key].enabled is exactly the happy-path assumption these flags reject. When an agent such as Claude Code runs tsc --noEmit after each edit, a stricter config turns a hidden runtime assumption into a concrete error with a file and line, which the agent can fix in the same loop before a human reviews the diff.

At Devya we treat the two flags as cheap guardrails. They cost mechanical fixes up front, then reject a whole class of "Cannot read properties of undefined" crashes in every future change, whoever or whatever writes it.

FAQ

Q: Is noUncheckedIndexedAccess included in "strict": true?

A: No. "strict": true enables neither noUncheckedIndexedAccess nor exactOptionalPropertyTypes; both must be set explicitly in compilerOptions. Since TypeScript 5.9, tsc --init adds both to newly generated configs.

Q: Does noUncheckedIndexedAccess affect for...of loops or tuples?

A: No. Values from for...of and Array.prototype.entries() keep their element type, and tuple positions within a known length stay non-optional. Only reads through an array index or an index signature gain | undefined.

Q: Why does if (arr.length > 0) not narrow arr[0]?

A: TypeScript does not connect an array's length to individual index reads, so arr[0] stays T | undefined after a length check. Read the element into a variable and check that variable instead.

Q: How do we fix "Consider adding 'undefined' to the types of the target's properties"?

A: That TS2375 or TS2379 message comes from exactOptionalPropertyTypes. Declare the property as prop?: T | undefined when undefined is a legitimate input, or include the key only when the value is defined, using a conditional spread.