PubTrivia is a Next.js 16 app for running a pub quiz: a marketing site of 79 public pages, a host screen, a player view on a phone, and the billing and admin behind them. It is 423 .ts and .tsx files, 53,705 lines, strict: true in tsconfig.json, and tsc --noEmit finishes clean:

$ time npx tsc --noEmit --incremental false --pretty false npx tsc --noEmit --incremental false --pretty false 6.50s user 0.37s system 174% cpu 3.947 total $ echo $? 0 

Three point nine seconds, zero errors. That sentence is worth almost nothing on its own, so I went and counted the places where the types are being asked to look away, and then turned on the strict options that strict does not actually turn on.

First, my grep was wrong

I started with the obvious thing and got 29 hits for an explicit any. Every single one was a false positive, and they were all in the same two files:

app/legal/terms/page.tsx:37 discontinue any part of the Service at any time app/legal/privacy/page.tsx:27 the data controller for any personal data collected app/legal/cookies/page.tsx:56 We do not set any other cookies 

Our content pages are TSX, the copy lives in JSX text, and JSX text is not a string literal, so the comment-and-string stripper I wrote handed the whole of our terms page to the regex as code. The word "any" appears a lot in a legal page.

So I counted with the compiler instead. ts.createSourceFile on each file, walk the tree, count node kinds:

if (n.kind === ts.SyntaxKind.AnyKeyword) counts.any++; else if (ts.isNonNullExpression(n)) counts.nonNull++; else if (ts.isAsExpression(n)) { n.type.getText(sf) === 'const' ? counts.asConst++ : counts.asExpr++; } 

232,925 nodes later:

escape hatch count
any 0
@ts-ignore / @ts-expect-error / @ts-nocheck 0
old-style <Type>value assertions 0
non-null assertion ! 52
as SomeType 143, across 54 files
as const 73
satisfies 6
unknown 64

Zero any in 53,705 lines is the number I was most surprised by, and I do not think it is discipline. It is that this codebase talks to four typed SDKs and one typed ORM, and the places that would otherwise need any get unknown or a cast to a named type instead. 35 of the 143 casts are as string, which is the shape of reading something out of a query string or a JSON column.

The six satisfies are all the same pattern, and if we had a style guide this would be in it:

] as const satisfies readonly ContentNode[] 

as const fixes the literal types so the registry's paths stay a union you can index by, and satisfies checks each entry against the contract without widening it. That line is the end of the file that generates 67 of our pages.

The 52 assertions, and the one file holding 17 of them

The ! count is lumpy. 17 of the 52 are in a single 73-line OAuth callback, and they are all the same two: the auth SDK types its user as nullable, the route only runs after a successful code exchange, and every subsequent line asserts it. 10 more are process.env.SOMETHING! at module scope in the database, Stripe and Supabase clients. The remaining 25 are spread one per file.

The honest fix for the first group is one narrowing early return at the top; the assertions then disappear by themselves. The honest fix for the second group is a tiny requireEnv() helper, which five of our modules could share. Neither is urgent, which is exactly why neither has happened.

The six flags strict does not include

strict: true is a bundle of other flags rather than one switch, and it is not the whole list. Here is what each of the remaining useful ones costs us, measured one at a time on a clean tree:

flag new errors
noUncheckedIndexedAccess 240
noPropertyAccessFromIndexSignature 106
exactOptionalPropertyTypes 34
noUnusedLocals 22
noUnusedParameters 6
verbatimModuleSyntax 4
noImplicitOverride 0
noFallthroughCasesInSwitch 0
useUnknownInCatchVariables 0
allowUnreachableCode: false 0

Three of them are free and genuinely new to us: noImplicitOverride, noFallthroughCasesInSwitch and allowUnreachableCode: false. They cost nothing because they were satisfied by accident rather than by intent, which is the usual reason a linter finds nothing. The fourth zero, useUnknownInCatchVariables, is a tautology: strict has implied it since TypeScript 4.4, so measuring it told me only that I had read the docs badly.

What the 240 actually are

This is the flag everyone recommends, and this is why we have not turned it on. noUncheckedIndexedAccess adds | undefined to every indexed read, which is correct, and it finds no bugs here. A sample from our round planner, which you can open at pub-trivia.app/tools/round-planner:

const startMinutes = useMemo(() => { const [hours, minutes] = startTime.split(":").map(Number) if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return 20 * 60 return hours * 60 + minutes }, [startTime]) 

Two errors on line 114, 'hours' is possibly 'undefined' and the same for 'minutes', on the line after the guard that handles exactly that case. Number.isFinite is not a type predicate, so it narrows nothing. The runtime behaviour is already right, and you can check it on the live page: clear the start time field and the running order carries on starting at 20:00, because an empty string splits into one element, minutes comes back undefined, Number.isFinite says no, and the function falls back to 1,200 minutes.

The other 238 fall into four groups:

The modulo guarantees it. A line picked by an index that was just reduced modulo the array length. Three of these are in the mascot code whose output you can see on the homepage:

export function lineFor(cue: MascotCue, seed: number): string { const lines = MASCOT_LINES[cue] return lines[Math.abs(Math.trunc(seed)) % lines.length] } 

The guard clause guarantees it. The hasOwnProperty check in our currency resolver, which decides which currency the pricing page quotes you:

return Object.prototype.hasOwnProperty.call(COUNTRY_CURRENCY, key) ? COUNTRY_CURRENCY[key] : BASE_CURRENCY 

The check is deliberate rather than stylistic, because reading the object directly would let a country code of constructor return a function. TypeScript cannot connect the check to the read, so the flag asks us to assert the result of the safest version of this lookup.

The fallback guarantees it. ROUND_TYPES.find(t => t.key === round.type) ?? ROUND_TYPES[0] is still | undefined, because the fallback is itself an indexed read.

The test wrote it. 88 of the 240 are in one integration test file, which indexes arrays it just built three lines earlier.

Add those up and the flag's effect on this codebase is to convert 0 non-null assertions into roughly 240, or to add about 240 runtime checks for conditions that cannot happen. The version I would actually accept is per-file: turn it on for the modules that parse input we did not create, and leave it off where the array and its index are declared four lines apart. TypeScript has no per-file compiler options, so that version does not exist.

The flag that is pure profit

noUnusedLocals found 22 things, and all 22 are dead:

app/dashboard/packs/page.tsx(1,1): 'redirect' is declared but its value is never read. components/PackBuilder.tsx(3,35): 'useOptimistic' is declared but its value is never read. components/PackBuilder.tsx(9,29): 'CardHeader' is declared but its value is never read. components/HowItWorksGrid.tsx(30,7): 'GRAD_H' is declared but its value is never read. utils/supabase/server.ts(1,35): 'CookieOptions' is declared but its value is never read. 

Eighteen unused imports, one unused constant, three unused destructured values. The useOptimistic import is my favourite, because it is the fossil of an approach we abandoned, still sitting in the import list of the component that ended up doing it another way. None of it costs a byte in the browser, since the bundler drops it, and all of it costs a reader a second of "is this used?".

noUnusedParameters is the one flag I would argue against. Five of its six errors are this:

export async function signIn(currentState: SignInState, formData: FormData) { 

currentState is unused because React's useActionState decides the signature, not us. The convention is to rename it _currentState, which means editing five server actions to silence a flag that found nothing. The sixth is a real one: a modal component takes a sessionId it never reads.

What we changed

Three free flags on. The 22 unused locals deleted. verbatimModuleSyntax's four complaints are four import { SelectTable } that should be import type, which is a two-minute fix with a real payoff, since a type-only import can never accidentally pull a module into a client bundle.

noUncheckedIndexedAccess stays off, with this post as the reason, and I would rather say that in public than leave a TODO nobody reads.

If you want to poke at the code the errors are in, it is all public: the round planner, the team name generator and the other free tools run entirely in your browser, with no account and no network request after the page loads. The 240th error is probably in one of them.