Every TypeScript team has a rule about @ts-ignore. Ours does too, and it works: zero occurrences across 610 source files.
Then I grepped for something nobody puts in a style guide:
grep -rn --include='*.ts' --include='*.tsx' \
--exclude-dir=node_modules --exclude-dir=.next \
'as unknown as' apps packages | wc -l
117.
Zero explicit suppressions, a hundred and seventeen implicit ones. That ratio is not a coincidence — it is what a ban creates when the ban only covers the syntax people remember to grep for.
The numbers, from a real repo
| Pattern | Occurrences in source |
|---|---|
@ts-ignore |
0 |
@ts-expect-error |
0 |
as any |
3 |
as unknown as |
117 |
eslint-disable* |
53 |
console.log |
4 |
Two more facts worth having. The 53 eslint-disables are not 53 decisions: 42 of them are one rule (@next/next/no-img-element), which is a single architectural choice about <img> tags, repeated. And the casts are not evenly spread — they cluster:
| File | as unknown as |
|---|---|
api/src/routes/admin.ts |
14 |
api/src/stories/worker.ts |
11 |
api/src/app.ts |
7 |
api/src/assistant/worker.ts |
6 |
web/src/lib/messages/index.ts |
5 |
api/src/stories/production.ts |
5 |
That distribution tells you what the casts are for. Admin routes parse request payloads. Workers deserialise rows that came back from the database as unknown. The i18n map indexes strings by a computed key. These are all boundaries — the places where a runtime shape meets a compile-time type and nobody wrote the parser.
Why this one survives code review
as unknown as Foo is two casts. The first erases the type to unknown, which is honest; the second asserts it back to whatever you want, which is the lie. Together they are semantically identical to as any, with three advantages:
-
It compiles under
no-explicit-any. If your lint config bansany— most do — this is the way around it. - It reads as deliberate. A reviewer skimming a diff sees a cast, not a suppression comment. There is no red flag to grep for in review.
-
It is what an agent reaches for. When a model is asked to make a type error go away,
as unknown asis the minimal edit that satisfies the compiler without adding a comment a human might question.
That last one is why the count grows on AI-assisted branches. The tool is not malicious; it is doing exactly what it was asked. The ban list just does not cover the escape hatch it found.
The gate I added
A grep in CI, with an explicit allowlist file — because the goal is not zero this week, it is "never again 117".
#!/usr/bin/env bash
# fail when the double-cast count grows past the recorded budget
budget=$(cat .cast-budget)
actual=$(grep -rn --include='*.ts' --include='*.tsx' \
--exclude-dir=node_modules --exclude-dir=.next \
'as unknown as' apps packages | grep -v 'as-unknown-as-allow' | wc -l)
echo "as unknown as: ${actual} (budget ${budget})"
if [ "$actual" -gt "$budget" ]; then
echo "New double casts. Narrow the type, or add the line to the allowlist with a reason."
exit 1
fi
Three properties matter more than the rule itself:
- A budget, not a ban. A hard zero on day one means a 117-line allowlist nobody reads. A ratchet — budget goes down when you fix one, never up — keeps the number honest.
-
The allowlist carries a reason.
// as-unknown-as-allow: stripe webhook payload is validated upstreamis a reviewable sentence. An empty allowlist entry is a comment that says nothing. - It runs in the same job as typecheck, so the count is visible in the PR that changes it.
If your setup has typescript-eslint installed, the equivalent rule is a restricted-syntax selector:
'no-restricted-syntax': ['error', {
selector: 'TSAsExpression > TSUnknownKeyword',
message: 'Double cast. Narrow with a parser or a guard instead.',
}]
I would still keep the grep: it works in repos with no ESLint, in CI scripts, and on the day the parser plugin upgrades and the selector stops matching.
The three patterns behind 90% of them
Reading the 117 by hand, they fall into three groups, each with a replacement that costs about ten minutes per file:
1. Parsing external data. JSON.parse(body) as unknown as JobRow becomes a schema parse at the boundary, and everything downstream is genuinely typed:
const JobRow = z.object({ id: z.string(), status: z.enum(['queued', 'running', 'done']) });
const row = JobRow.parse(JSON.parse(body)); // typed, and it fails loudly on drift
2. Reading database rows into a domain type. The cast moves the failure from "somewhere later, with a stack trace that does not mention the column" to "here, at the read". A small mapper function per table beats a cast per caller.
3. Indexing a translation map by a computed key. messages[key] as unknown as string usually wants satisfies Record<Locale, string> on the definition and a typed accessor — the cast disappears because the shape was always there, just unproven.
When the double cast is fine
-
Test doubles. A partial implementation of a large interface is a cast-shaped problem; keep it in tests and let the budget ignore
*.test.ts. -
Module augmentation for untyped third-party packages. You are asserting something the package should have declared. Put it in one
types/file with a comment, and it never needs to appear twice. -
Migration boundaries. When a column changes type, one cast with a
// migration: remove after 2026-12marker is cheaper than a parallel type tree.
Everything else is a parser you did not write yet.
FAQ
Is as unknown as slower at runtime? No. TypeScript erases both casts; the cost is entirely in what the compiler stops checking.
Does satisfies replace it? Sometimes. satisfies proves a value matches a type without widening it — which is exactly the i18n case — but it cannot rescue a value you did not construct.
Why not ban it outright? Because a ban without a migration path produces an allowlist longer than the problem. The budget is the migration path.
Would a stricter tsconfig catch these? noUncheckedIndexedAccess and exactOptionalPropertyTypes remove a chunk of the reasons people reach for a cast. They also produce a lot of red on day one. Ratchet the same way.
The counts above came from one repository and one grep; your ratio will differ, and the interesting number is the ratio, not the total. If you run the same audit and get zero double casts, I want to know what your boundaries look like, because that is a codebase with parsers everywhere.
Top comments (8)
grepping for casts is such a simple trick — definitely stealing this
as unknown asis the sneakiest one.Love that the allowlist entry has to be a reviewable sentence. One gap in the grep itself: it won't catch the cousins, like
as any as Fooor a double cast split across lines by Prettier. If you want an AST-aware version of the same budget, typescript-eslint's no-unsafe-type-assertion rule (typed linting) flags assertions that widen past what the compiler can prove, including those variants, and you can still keep your per-file allowlist through eslint-disable comments with a required description.The clustering is what makes the budget approach convincing here: 14 of the 117 in admin.ts and 11 in stories/worker.ts means this is not random sloppiness, it is a couple of boundary types that never got parsers. My question is about the ratchet mechanics: is your budget a single repo-wide count, or per file? A single count could let a new cast in a low-risk file hide behind a cleanup in admin.ts, whereas per-file budgets would force each boundary to improve on its own, but they would also be noisier to maintain.
I tried the per-file version while reading this, mostly to check whether the maintenance cost you're worried about is real. Across the two app trees, the 117 casts live in 38 files — 15 of them have exactly one, and the two files you spotted hold 25 — so the baseline is a ~40-line JSON, which is small enough that I stopped worrying about the noise. The churn case turns out to be a rename: one removed key plus one added key in the diff, which reads as honest rather than noisy. And the laundering you described gets caught straight away — cleaning three out of admin.ts while adding one in a one-cast file moves the repo total down by two, so a single counter stays quiet, while the per-file diff flags both lines. So I'm with you on per-file as the mechanism. The only thing I'd still push back on is leaving it there permanently: those two files are effectively the whole job, and once they get parsers, a single number is enough to police what's left.
the
as unknown aspart hit a little too close to homeThe
no-restricted-syntaxselector in the gate section is wider than the grep.TSAsExpression > TSUnknownKeywordmatches anyx as unknown, including a single cast likeJSON.parse(s) as unknown, which is the honest version. So the lint count and the grep count will drift apart. To hit only the double cast, select the outer node:TSAsExpression[expression.type='TSAsExpression'][expression.typeAnnotation.type='TSUnknownKeyword']. esquery handles the dotted attribute path. Also, grep counts lines, so two casts on one line count once. I'd run both against the same tree once and check the numbers agree before trusting either as the ratchet.The grep trick is underrated because it turns a vague code review argument into a concrete number. We started tracking cast counts per file and it changed the conversation.