DEV Community

Daniel Pertu
Daniel Pertu

Posted on

72,683 lines of TypeScript with one any in them, and two compiler flags that would add 455 errors

Nakodo is 72,683 lines of TypeScript and TSX across src, with strict: true and a clean tsc --noEmit. That sentence means less than it sounds like, so I went and counted the places where the types are being asked to look the other way. Then I turned on the strict options that strict does not actually turn on, and counted the damage.

The census

escape hatch count
any 1
@ts-ignore 0
@ts-expect-error 0
eslint-disable 6
non-null assertion (!.) 12
as unknown as 10, seven of them in tests
as SomeType 142
as const 160
satisfies 15

The single any is this, and it carries the only no-explicit-any suppression in the repository:

// An API's JSON response body, or {} if it isn't JSON.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function readJson(res: Response): Promise<any> {
  try {
    return parseJson(await res.text());
  } catch {
    return {};
  }
}
Enter fullscreen mode Exit fullscreen mode

The function above it in the same file returns unknown from the same parse, which is the honest type. readJson exists because unknown at that boundary means every caller writes a cast, and 20-odd casts spread across the providers we read from are worse than one any with a comment on it. I am not proud of the line, but I know exactly where it is, which is the point of counting.

The other five eslint suppressions are three @next/next/no-img-element (remote avatar URLs are signed and expire, so they cannot go through the image optimiser) and two react-hooks/exhaustive-deps in two long editors. The whole eslint config is 20 lines: eslint-config-next's core-web-vitals and typescript presets, no custom rules, and the only ignores are build output and the Remotion folder.

142 assertions, and what they are actually for

as has a bad reputation it mostly deserves, so I broke the 142 down by target type. The top of the list:

 30  as React…        mostly CSSProperties and component prop shapes
  5  as YouTube…      API response shapes
  5  as Stripe…       SDK shapes in tests
  5  as JSON…         JSON-LD node types
  4  as T             inside two generic helpers
Enter fullscreen mode Exit fullscreen mode

Against those 142 there are 160 as const (not an assertion in the dangerous sense, it is a narrowing) and 15 satisfies. Nine of the fifteen read as const satisfies X, which is the pattern I would put in a style guide if we had one:

} as const satisfies Record<string, { tokens: number; window: Duration }>;
Enter fullscreen mode Exit fullscreen mode

as const fixes the literal types, satisfies checks the object against a contract without widening it. You keep the exact keys for autocomplete and lookup types, and you still get an error if one entry is malformed. The thing it replaces is const x: Record<string, Thing> = {...}, which throws the key names away, and as Record<string, Thing>, which throws the checking away.

The twelve non-null assertions, one at a time

Twelve ! in 72,683 lines, in twelve different files, and they fall into three groups.

"The union guarantees it."

const statuses = THREAD_VIEWS.find((v) => v.key === key)!.statuses;
Enter fullscreen mode Exit fullscreen mode

key is a union type, THREAD_VIEWS is a constant array with an entry for every member of it, and the compiler cannot connect those two facts. The honest fix is a Record<ThreadView, …> instead of an array, which costs ordering, which the UI needs.

"The arithmetic guarantees it."

const place = `in ${there.split("/").at(-1)!.replaceAll("_", " ")}`;
Enter fullscreen mode Exit fullscreen mode

String.split never returns an empty array. Every one of these would be flagged by a flag I will come to in a moment.

"The filter guarantees it", which is the interesting one.

recipients: rows
  .filter((r) => isPlausibleEmail(r.email))
  .map((r) => ({ email: r.email!.trim().toLowerCase(), ... })),
Enter fullscreen mode Exit fullscreen mode

isPlausibleEmail is already a type predicate:

export function isPlausibleEmail(email: string | null | undefined): email is string {
Enter fullscreen mode Exit fullscreen mode

and it does not help here at all. The predicate narrows the argument, which is r.email, inside the callback. It cannot narrow r, and there is no mechanism in TypeScript for carrying "the email property of this object is a string" out of a callback and into the array's element type. So the next line pays for it with a !.

The fix is a second predicate at the level the filter is actually working on:

const hasEmail = (r: Row): r is Row & { email: string } => isPlausibleEmail(r.email);
Enter fullscreen mode Exit fullscreen mode

Six extra words and the assertion disappears. What I like about finding this by counting is that nobody reviewing the original line would call it wrong, and nobody reading the predicate's signature would guess it was not doing the job.

Then I turned on the flags strict does not include

strict: true is eight flags. It is not every flag. Two of the ones left out change how much the compiler trusts you about data, and both are off in our tsconfig.json, which is otherwise the one create-next-app wrote.

So I ran the compiler twice more. No code changes, one extra option each time:

npx tsc --noEmit                                 # 0 errors
npx tsc --noEmit --noUncheckedIndexedAccess      # 345 errors in 89 files
npx tsc --noEmit --exactOptionalPropertyTypes    # 110 errors in 38 files
Enter fullscreen mode Exit fullscreen mode

noUncheckedIndexedAccess: 345 errors. This is the flag that says array[0] is T | undefined, because indexing an array proves nothing. 299 of the errors are in src, 46 in scripts, and 47 of them are in test files. The error codes are mostly TS2532 (object is possibly undefined) and TS18048 (possibly undefined), with TS2345 on top of that where a possibly-undefined value is passed to something that wants a value. One script accounts for 40 of them on its own.

Five of those errors are lines I quoted in another post, about how our Server Actions turn a validation failure into one sentence:

if (!parsed.success) return { error: parsed.error.issues[0].message, email };
Enter fullscreen mode Exit fullscreen mode

issues is typed as an array. An unsuccessful parse always has at least one issue, and the type does not say so, and under this flag the compiler is right to complain about all five copies. The fix there is issues[0]?.message ?? "fallback", which is a real improvement: it is also what happens if a future zod ever returns an empty issue list, and "undefined" rendered into a form field is a bug a user would have to report.

exactOptionalPropertyTypes: 110 errors. This is the flag that distinguishes "property absent" from "property present and undefined". Almost all 110 are one shape, TS2379, and they come from the idiom of building an options object with spread:

Argument of type '{ trigger: "manual"; timeBudgetMs: number; maxSearches: number | undefined; }'
is not assignable to parameter of type '{ …; maxSearches?: number; }'
with 'exactOptionalPropertyTypes: true'.
Enter fullscreen mode Exit fullscreen mode

We pass undefined for "not specified" all over the place, and the receiving types say maxSearches?: number, which under this flag means "either absent or a number, but not present-and-undefined". Every one of those 110 is a real distinction that our code does not make, and honestly does not need to make. This is the flag I will not be turning on.

What I take from the two numbers

455 errors in a codebase with zero errors is not a sign that the types are bad. It is a measurement of where the type system has been taking my word for it, and the distribution tells you which kind of trust each flag is testing.

The 345 are concentrated and mechanical: array and record indexing, in loops, in parsers, in tests. Fixing them means ?. and ?? and a handful of real guards, and the ones in parsing code are the ones that would catch a genuine bug, because that is where the data comes from somebody else. I am going to do the src ones a directory at a time, starting with the files that read other people's data, and then switch the flag on so it stays fixed. 47 of them being in tests is a nice bonus: those are cheap to fix and they also make the tests read better.

The 110 are uniform and philosophical, and fixing them would mean writing maxSearches?: number | undefined in a hundred type declarations to describe a difference our code never acts on. Knowing that is worth the 40 seconds the experiment took.

If you have strict: true and you have never run those two commands, the second one is the interesting one. You get a number, you get it in under a minute, and if the number is zero you have learned something good about your codebase for free.

For the record, none of this is visible from outside, which is how it should be: the 63 generated pages at /guides and the four calculators are the output of the types, and the only one of them I would change my mind about is the any on the function that reads other people's JSON.

Top comments (0)