I went looking for @ts-ignore in CogniPrep expecting a handful. There are none. No @ts-expect-error either, in any of the 2,939 TypeScript files across app, components, lib, __tests__ and scripts.
That looked like a good result for about a minute, until I counted the other way out.
111 as unknown as
59 : any (in a type position)
34 as any
747 non-null assertions (154 in source, 593 in tests)
0 @ts-ignore / @ts-expect-error
Suppressing an error tells the next reader where the compiler lost. A cast does not. It compiles clean forever and nothing in pnpm verify will ever mention it again.
Count first, and expect your grep to be wrong
A plain grep for as any returned 48. Fourteen of those were English.
"Management interviews ask the same kinds of questions as any other interview"
"the rate limit and CSRF check as much as any other destructive endpoint"
"Not adaptive, as far as any published source says"
Nine more came out of : any the same way, from sentences like "Absent: any score report" in page copy. Prose and type annotations share a lot of syntax once you are grepping for two-word fragments, and this codebase is half content. Requiring the match to be followed by a character that can actually close a type (;,)]}>|&[ or end of line) got the counts down to 34 and 59. Both of my first two figures were too high, which is worth saying out loud, because the interesting part of this exercise is the ratios between the categories and a 30 percent error in one of them changes the conclusion.
64 of the 111 casts are the same cast
Here is the distribution of what as unknown as points at:
31 Record<string, number>
23 Record<string, number> | undefined
7 Record<string, unknown>
2 Record<string, Record<string, number>>
1 Record<number, number>
48 everything else
Sixty-four of 111 casts land on a Record, and 54 of those on one key-value shape. They are split 34 in tests and 30 in library code, which is the tell: when a test and the module it tests both need the same cast to say the same thing, the cast is not an opinion about one line, it is a type that was never written.
In our case it is a results bag. A finished practice session carries a metrics object, the shared type knows the fields every test has in common, and each individual test adds its own. Every reader of a test-specific field therefore casts the bag to a plain record before indexing it. The fix is not a lint rule and it is not stricter review, it is a generic on the bag or a per-test type that the shared one extends. 64 occurrences is a feature-sized job, which is exactly why nobody has done it, and counting them is what turns "we have some casts" into a thing with a size.
The other 47 are genuinely one-offs: a few test fixtures, a handful of places where a driver result is narrower than the ORM's return type.
Two imports from one package, two different casts
My favourite single line in the audit:
import disposableList from 'disposable-email-domains';
import wildcardList from 'disposable-email-domains/wildcard.json';
const exactDomains = new Set<string>(
(disposableList as unknown as string[]).map((d) => d.toLowerCase())
);
const wildcardDomains = (wildcardList as string[]).map((d) => d.toLowerCase());
Same package, same JSON shape, two different cast strengths. The package's main is a .json file and it ships no type declarations, so the default import needs the full unknown laundering. The deep import of a sibling .json resolves through resolveJsonModule and only needs a single-step assertion. Nothing about the data differs. The only difference is which resolution path TypeScript took to reach it.
The catch clauses got it right
Because useUnknownInCatchVariables has been on by default since TypeScript 4.4, every catch (error) in this codebase is already typed unknown, and there are 201 of them. Seven declare catch (error: any) and opt out. None declares : unknown, which would be a no-op anyway.
So the one place the language made the safe thing the default, the codebase is 97 percent safe by accident. Everywhere the safe thing needs typing out by hand, it is a cast.
See it
The single Record<number, number> cast in the whole repository sits in a public endpoint, over a jsonb column, so you can check what it actually asserts without any of our source:
curl -s https://cogniprep.app/api/games/population-stats/balloon
{"gameId":"balloon","mean":54.11,"median":54,"stdDev":6.47,
"percentiles":{"10":45.7,"20":49,"25":50,"40":53,"50":54,"60":56,"75":59,"80":60,"90":62},
"sampleSize":2768,"lastUpdated":"2026-11-05T02:00:14.120Z"}
The sample size and timestamp move, the shape does not. Look at the keys of percentiles. They are strings, because JSON has no other kind of key, and the route hands that object to the client as Record<number, number>. The code works, since obj[10] coerces to obj['10'] on the way through, but the type says something the wire format cannot say, and anyone writing Object.keys(percentiles).map(Number) downstream is doing the conversion the type claims already happened.
Two fields over, the same route does the opposite and I would keep it: mean, median and stdDev each go through a typeof === 'string' guard before parseFloat, because the Postgres driver can hand back a numeric column as a string. That is three lines of runtime code instead of one cast, and it is the only version of those three fields that is true in production.
The scores behind that endpoint are real, recomputed nightly, and the practice tests they come from are at cogniprep.app/games if you want to see what gets measured.
Count your casts by target type, not by file. The histogram is the backlog.
Top comments (0)