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.
