NoWaterProgramming

What TypeScript's Two Strictest Flags Actually Cost: 129 Errors Across 39,824 Lines

We turned on noUncheckedIndexedAccess and exactOptionalPropertyTypes across five production codebases and counted every error. The cost per thousand lines varied by 24x, and half the work was not where you index.

12 min read
Share:

Measured against TypeScript 5.9.3 on 2026-08-20.

noUncheckedIndexedAccess and exactOptionalPropertyTypes are the two flags you get told to consider once strict: true is already on. The advice rarely arrives with a number attached, so we went and got one: each flag turned on across five production codebases, tsc --noEmit, count what falls out.

129 new errors from noUncheckedIndexedAccess and 55 from exactOptionalPropertyTypes, across 39,824 lines. That is 3.24 and 1.38 errors per thousand lines on average, and the average is the least useful number here: the per-codebase cost of noUncheckedIndexedAccess ranged from 0.22 to 5.23 per thousand lines. Same flag, same compiler version, comparable project sizes, a 24x spread.

Two findings that changed how we think about enabling these:

  • Only half the noUncheckedIndexedAccess errors appear where you index. The rest surface somewhere else entirely, which is why the "I will just add a guard at each lookup" estimate is consistently wrong.
  • exactOptionalPropertyTypes behaves in the opposite way. Its errors are concentrated and mechanical, and there are far fewer of them than its reputation suggests.

If you want the config advice rather than the measurement, our TypeScript best practices guide covers which flags to run and why. This post is the evidence behind one paragraph of it.

What We Measured

Neither flag is part of strict. The TSConfig reference lists nine options that strict turns on, and these two are not among them; noUncheckedIndexedAccess arrived in TypeScript 4.1 and exactOptionalPropertyTypes in 4.4, both as separate opt-ins. That is the whole reason the question exists. You do not inherit them by being a responsible person who set strict: true, so at some point somebody has to decide.

The five codebases are our own, which is the measurement's main limitation and we get to it below. All five run TypeScript 5.9.3 and strict: true, and none had either flag on.

The method was three tsc --noEmit runs per project: a baseline, then each flag added alone on the command line so it overrides the config file. Reporting the delta over baseline rather than the raw count matters, because one project carries a pre-existing unrelated error that would otherwise be counted twice.

CodebaseLines+noUncheckedIndexedAccessper kLOC+exactOptionalPropertyTypesper kLOC
Next.js blog (this site)6,076243.9510.16
Node/Hono media API12,962534.0980.62
Next.js content site9,28720.22131.40
MV3 browser extension6,311335.23182.85
Node/Hono tools API5,188173.28152.89
Total39,8241293.24551.38

Lines are non-generated .ts and .tsx, excluding node_modules, build output and declaration files.

The Spread Is the Finding

The Next.js content site produced 2 errors from noUncheckedIndexedAccess. The browser extension, two thirds its size, produced 33.

Size does not explain that and neither does age or team. What explains it is how much of the code reaches into arrays and records by key. The content site reads its data through typed module imports and props, so there are very few index reads to add | undefined to. The extension parses messages, walks DOM collections and looks things up in maps by string key, and every one of those is exactly the operation the flag exists to flag.

"Should we enable noUncheckedIndexedAccess" gets treated as a question with a general answer, the kind a sufficiently experienced person is supposed to already know. It is a question about the shape of your particular code. The range across five codebases from one team writing to one style guide was 24x, and across codebases that differ more than ours do it will be wider.

The good news is that it takes about a minute to find out.

Measure Your Own Repo

One command, no config change, nothing to revert:

npx tsc --noEmit -p tsconfig.json --noUncheckedIndexedAccess

Command-line compiler options override the ones in tsconfig.json, so this tells you the cost without committing to it. Run it once plain first to get your baseline, because the number that matters is the difference.

To see the shape of the work rather than just its size, group the output by error code:

npx tsc --noEmit -p tsconfig.json --noUncheckedIndexedAccess 2>&1 \
  | grep -oE 'error TS[0-9]+:' | sort | uniq -c | sort -rn

That second command is what produced the next section, and it is the one we would run first next time.

Half the Work Is Not Where You Index

Here is what the 129 noUncheckedIndexedAccess errors actually were:

CodeMeaningCount
TS18048'x' is possibly 'undefined'52
TS2345Argument not assignable to parameter31
TS2322Type not assignable to type22
TS2532Object is possibly undefined14
TS2769No overload matches this call5
TS2538undefined cannot be used as an index type3
TS2488Type must have a [Symbol.iterator]() method1
TS2339Property does not exist on type1

TS18048 and TS2532 are the errors you expect: you read items[0], you touched something that might not be there, the compiler says so at the point of use. Together they are 66 of 129, almost exactly half.

The other 63 are propagation. The | undefined the flag added does not stay put. It travels into a function call (TS2345), into an assignment or a return (TS2322), into an overload resolution that no longer matches (TS2769), and into places where the value was itself being used as a key (TS2538). The error surfaces at the destination, often in a different file from the lookup that caused it.

The extension is the extreme case: 25 of its 33 errors were propagation, and only 8 were the direct possibly-undefined pair. Fixing those 8 lookups properly resolved most of the rest, but you cannot see that from the error list, and if you triage by opening errors in order you will spend the first hour confused about why the compiler is complaining about a function signature you did not touch.

The practical consequence is that error count overstates the number of decisions. Many of those 63 collapse the moment you narrow at the source. It also means the fix-by-adding-! shortcut is worse than it looks: each non-null assertion silences one error at the index site and does nothing for the propagation, so you end up sprinkling assertions and still having errors.

exactOptionalPropertyTypes Is a Different Shape

55 errors total, and 45 of them are the flag's own two codes:

CodeMeaningCount
TS2379Argument not assignable with exactOptionalPropertyTypes: true28
TS2375Type not assignable with exactOptionalPropertyTypes: true17
TS2769No overload matches this call5
TS2322Type not assignable to type3
TS2345Argument not assignable to parameter2

82% land in two dedicated codes that name the flag in the message. That makes them trivially greppable, trivially triaged, and mostly the same fix repeated: something writes { foo: undefined } where the type says foo?: string, and the flag insists those are different things.

They are different things, and the flag is right. { } and { foo: undefined } serialise differently, behave differently under in, and differ when spread over defaults. The cost is that building objects incrementally, which is normal and fine, now needs conditional spreads instead of assigning undefined as a placeholder.

Our expectation going in was that this flag would be the painful one, because it changes the meaning of an extremely common pattern. The measurement says otherwise: on our code it is roughly a third of the volume of noUncheckedIndexedAccess and a small fraction of the thinking.

Where This Measurement Is Weak

Five codebases from one team is not a sample anyone should generalise from without checking their own, and the whole point of the section above is that you can check in a minute.

More specifically:

  • They are our own codebases. The media API, the tools API, the browser extension and the content site are our products, and this blog is the site you are reading. That is worth saying twice, because it is the limitation that bites hardest: five projects written by the same people to the same conventions will share habits, and shared habits show up as shared error patterns. A team that reaches for Record<string, T> where we reach for a Map, or that destructures where we index, would get a different table out of the same commands. The spread we found across our own five is the floor on the variation, not the ceiling.
  • Error count is not effort. As the propagation finding shows, one fix can clear several errors. It is a proxy for cost, not a measure of it, and it makes noUncheckedIndexedAccess look worse than a careful fix-and-recount would.
  • We measured turning the flag on, not living with it. The ongoing tax, every future array read needing a guard, is not in these numbers, and that is the part that decides whether a team keeps the flag or quietly removes it three months later.
  • Nothing here is above 13,000 lines, so whether these ratios hold at 200,000 we do not know.
  • Both flags were measured alone. Turning both on is not necessarily 129 plus 55, since some code errors under either.

So Should You Turn Them On

For exactOptionalPropertyTypes, on this evidence, probably yes. Low volume, concentrated error codes, repetitive fixes, and it removes a real class of serialisation bug.

For noUncheckedIndexedAccess, it depends on something you can measure and we cannot guess. If your number comes back under about one per thousand lines, the flag is close to free and you should take it. If it comes back at five, understand that you are signing up for real work, that half of it will be in files you did not expect, and that the temptation to close it out with non-null assertions will produce a codebase that is no safer than before you started.

This blog runs strict: true and neither flag. 24 errors on 6,076 lines is not a large bill, but the code here reads a lot of arrays, and we would rather not carry the assertions that would come with paying it.

FAQ

Is noUncheckedIndexedAccess part of strict?

No. strict turns on nine options, and neither noUncheckedIndexedAccess nor exactOptionalPropertyTypes is among them. Both are separate opt-ins, added in TypeScript 4.1 and 4.4 respectively. Setting strict: true does not give you either one.

How many errors will noUncheckedIndexedAccess produce in my project?

Nobody can tell you without running it. Across five codebases of ours it ranged from 0.22 to 5.23 errors per thousand lines, a 24x spread driven by how often the code indexes into arrays and records. Run npx tsc --noEmit -p tsconfig.json --noUncheckedIndexedAccess and compare against a plain baseline run.

Why does noUncheckedIndexedAccess report errors in files I did not change?

Because the | undefined it adds propagates. Half of our 129 errors were not possibly-undefined complaints at the lookup, but assignment, argument and overload errors where that value was eventually used. Narrow at the source and most of the downstream errors clear together.

Can I try a compiler flag without editing tsconfig.json?

Yes. Command-line options override the config file, so npx tsc --noEmit -p tsconfig.json --noUncheckedIndexedAccess reports what the flag would cost while changing nothing on disk.

Is exactOptionalPropertyTypes worth enabling?

On our measurement it is the cheaper of the two: 55 errors against 129, with 82% falling into two dedicated error codes that name the flag in the message and usually take the same fix. The cost is that assigning undefined as a placeholder while building an object incrementally stops working, and you use conditional spreads instead.

What is the difference between a missing property and one set to undefined?

With exactOptionalPropertyTypes off, TypeScript treats foo?: string as accepting undefined explicitly. With it on, { } and { foo: undefined } are distinct types. It matters because they behave differently at runtime: in reports them differently, JSON.stringify drops one and not the other, and spreading over defaults overwrites with undefined in the second case.

Sources

Checked 2026-08-20.

Error counts are our own measurement, produced by the commands given above against TypeScript 5.9.3 on 2026-08-20.

Related Posts

17 min read
How route handlers actually behave in Next.js 16: GET is no longer cached by default, params are async, and most of what you need is a server action instead. With a real deployment that is not on Vercel.
By NoWaterProgramming Team
14 min read
Which strict flags earn their keep and what each one costs, when to reach for @ts-expect-error over @ts-ignore, why catch variables are unknown, and how to type the edges where outside data enters.
By NoWaterProgramming Team