Last updated on
I banned type assertions and my "unavoidable" casts turned out to be avoidable
I turned on an ESLint rule to ban type assertions and got 18 violations. My first instinct was to suppress every one of them and call it done. That instinct was wrong, and being wrong about it twice is the whole story here.
A type assertion is as X, as unknown as X, as any, or the non-null !. It tells the compiler to stop checking and trust you. Sometimes you're right. When you're wrong, you've created a runtime bug the types promised couldn't happen, because the check that would have caught it is exactly the check you turned off.
So I turned the rule on for real, as an error, in CI. And then I did the lazy thing.
The lazy enforcement trap
Eighteen violations. The fast path is obvious: wrap each one in // eslint-disable-next-line, write a plausible-sounding justification, ship it. The build goes green. The rule is "on." Everybody moves on.
Except a rule you satisfy with suppressions bought you nothing. You've spent effort to end up exactly where you started, now with a comment claiming it's fine. It's enforcement as theater. The whole reason to ban as is that an assertion is an unproven claim, and papering over the ban with disables is just the same unproven claim wearing a hall pass.
A disable is a claim too, and every claim should be challenged. So instead of writing 18 justifications, I went back and asked each one a single question: is this cast actually necessary, or does it just look necessary?
Six of seven dissolved
I'd narrowed the 18 raw violations down to 7 spots I'd flagged as genuine gaps. When I challenged those 7, six of them turned out to be avoidable. Not with clever tricks, but with the boring, type-safe move that was available the whole time.
Here are the three worth seeing in full. The rest are in a table below.
Annotate instead of assert
A restore script reads an encrypted backup, decrypts it, and parses the JSON:
const snapshot = JSON.parse(json) as {
meta?: { createdAt?: string; collections?: string[] }
collections?: Record<string, unknown[]>
globals?: Record<string, unknown>
}This felt unavoidable. JSON.parse returns something shapeless, I know the shape, I'm naming it. That's what as is for, right?
But look at what JSON.parse actually returns: any. And any assigns to anything without complaint. So the assertion isn't doing any narrowing at all. I can move the type to the left of the = and delete the cast:
const snapshot: {
meta?: { createdAt?: string; collections?: string[] }
collections?: Record<string, unknown[]>
globals?: Record<string, unknown>
} = JSON.parse(json)Same runtime, same shape on snapshot, zero assertions. The difference matters the moment the shape is wrong. An annotation is a claim the compiler will hold the rest of the function to, while as on an any is a claim it can't even check. The transferable rule: when a value is already any, annotate the target instead of asserting the source. You weren't narrowing anything, you were only choosing which side of the = to write the type on, and the left side keeps the compiler in the loop.
Parametrize the generic
This one is Payload-specific on its face, but the lesson isn't. I had three near-identical casts in a rich-text converter, one per content block:
codeSnippet: ({ node }: { node: { fields: unknown } }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- the block key guarantees this shape
const fields = node.fields as CodeSnippetFields
return <CodeSnippet code={fields.code} language={fields.language} />
}The story I told myself: the library types node.fields as unknown, and only I know that under the codeSnippet key the fields have a specific shape, so I have to assert it.
That story skipped a question I hadn't asked. Why is fields unknown in the first place? Because the generic that produces it was never given its type arguments. The converter function is generic over the set of node types it handles, and I'd been using it bare. Feed it the actual block interfaces and the library narrows node.fields per key on its own:
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<CodeSnippetBlock | TerminalBlock | MermaidBlock>
export const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
codeSnippet: ({ node }) => (
<CodeSnippet code={node.fields.code} language={node.fields.language} />
),
// terminal, mermaid: same, fully typed, no casts
},
})Three casts and three hand-written shape types, gone, replaced by one line of type arguments. The rule: an unknown coming out of a generic is often a generic you forgot to parametrize. Before you assert the result, check whether the type you want was something you were supposed to pass in.
Fix the source type, once
An upload node's value was typed unknown, and every place that read it re-asserted:
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const media = node.value as Media | number | undefinedA cast at the use site is a confession that the type at the definition site is wrong. The value is a populated Media object or an unresolved relation id, so say that, once, where the field is declared:
// was: value?: unknown
value?: Media | numberEvery use site then reads node.value with the right type and no cast. Fixing the source had a side effect I didn't expect: a test fixture that had been passing a half-populated Media object suddenly failed to typecheck, because the tighter type revealed it was missing required fields. The cast hadn't just been redundant, it had been hiding an under-specified test. The rule: when you're casting the same value in more than one place, the cast belongs at the declaration, not the use. Tightening it there often flushes out the fixtures that were only ever valid because nothing was checking.
The one that was actually unavoidable, until it wasn't
That left one cast I was sure was real. A component bridges my codebase's loose rich-text type to the rich-text library's precise internal one:
function LexicalHtml({ content }: { content: LexicalContent | null | undefined }) {
if (!content) return null
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const data = content as unknown as RichTextData
return <RichText data={data} converters={jsxConverters} />
}The two types are the same JSON at runtime, but one is typed loosely on my side and the other precisely by the library, and they aren't structurally assignable. I couldn't even import the library's type directly to line them up. as unknown as, the worst kind, the double cast that erases all safety, genuinely looked like the only way across. I wrote the justification. I was ready to keep it.
Then I did the one thing I should have done first: I measured the assignment instead of assuming it. A throwaway probe. Declare the target type, assign the real value, let the compiler answer:
// does the actual caller data fit the library's type, with no cast?
const probe: RichTextData = realPostContent // Post['content'] from generated typesIt compiled. No error. The value my callers actually pass, the generated Post['content'] type, was structurally assignable to the library's data type all along. The cast had never been about the real data. It existed only because I'd typed the component's prop as the loose local shape, LexicalContent. The parameter type was the one thing standing between the value and the type it already matched.
So I typed the prop as what the library actually wants:
type RichTextData = ComponentProps<typeof RichText>["data"]
function LexicalHtml({ content }: { content: RichTextData | null | undefined }) {
if (!content) return null
return <RichText data={content} converters={jsxConverters} />
}The content flows straight through. Both call sites still typecheck. The double cast, the one I'd been most confident about, turned out to be the emptiest of the bunch.
This is the lesson I'd have missed if I'd stopped at "mostly avoidable." A loosely-typed boundary manufactures casts. You reach for as at the boundary because the two sides don't line up, never noticing that the only reason they don't line up is that you loosened one side yourself. Type the boundary precisely, and probe assignability before you assert. The compiler will tell you the truth about whether the values fit. You just have to ask it instead of guessing.
The rest, briefly
The other fixes followed the same handful of moves:
value as CodeBlockLanguageafter aSet.has()check became a user-defined type guard,value is CodeBlockLanguage, so the membership check itself narrows. Make the runtime check prove the type instead of checking and then asserting.- Three casts normalizing an
unknowndatabase result becameinnarrowing plusArray.isArray.typeof,in, andArray.isArrayall narrowunknownwithout a claim. where as { _status?: ... }in a test helper becameinguards walking the object. Inspecting a shape you can narrow into is narrowing, not asserting.kept.pop()!(the non-null!) becameconst oldest = kept.pop(); if (!oldest) break. Handle the empty case instead of swearing it can't happen.result.docs as unknown as Record<string, unknown>[], used to strip auth fields, became anobject-typed helper built onObject.entries. An interface isn't assignable toRecord<string, unknown>, but iterating its entries only needsobject.as neveron a test mock stub becamevi.mocked(fn, { partial: true }). The tool already has a knob for a partial stub, so use it instead of lying to the type.
Two of those were small surprises worth keeping. An interface is not assignable to Record<string, unknown> because it has no index signature, which is why the backup helper takes a plain object and rebuilds via Object.entries. And Vitest's vi.mocked(fn, { partial: true }) exists precisely so a stub can provide a subset of a big interface without an as never. The escape hatch was a built-in feature I'd been reinventing with a cast.
When a disable is actually legitimate
I want to be honest about the counter-case, because "zero" can turn into its own kind of dishonesty. There are real spots where every type-safe fix is worse than the cast.
The clearest one is narrowing a large, deeply recursive structure. A sound type guard for it would have to walk the entire tree at runtime to actually prove the shape. A guard that skips the walk and returns true isn't a guard, it's an assertion wearing a value is Foo signature, with the added downside that it looks trustworthy. In that case the honest move is to fix the type at the source if you can, and if you truly can't, write the cast with a disable that says two things: why it's sound, and why none of the fixes apply. That second clause is the one people skip, and it's the one that makes the next reader trust the first.
The bar isn't "never." The bar is: exhaust the fixes, then document the exhaustion.
What "zero" actually buys
The repo landed at zero assertion disables. That's not a trophy, it's a property. The rule is now satisfied by real code rather than by suppressions, which means the count of as in the codebase is a number that can only go down. A new cast can't slip in quietly. It has to be a genuinely-argued disable, or it doesn't merge.
The thing I'd tell my past self, the one about to suppress all 18: a type assertion is you telling the compiler you know better. Most of the time you don't, you just haven't asked it the right question yet. Write the probe. Measure the assignment. Type the boundary. The casts you were sure you needed have a way of dissolving the moment you stop asserting and start checking.
Comments
No comments yet. Be the first to comment.