as unknown as: the double cast that hides real bugs
published
TL;DR
x as unknown as T is not a stronger version of x as T. It is the absence of a check. The single cast has a guardrail — TypeScript refuses an assertion between types that do not sufficiently overlap — and routing through unknown deletes that guardrail. What you are left with is an unverified claim about runtime that the compiler then trusts in every line downstream. When the claim is wrong, the error surfaces far away from the cast, and nothing in tsc, unit tests, or the build will point back at it.
The problem
We swapped an image library and shipped a version of a background-removal tool that failed on every single input with returned no image. Typecheck: clean. Unit tests: green. Production build: fine.
The whole bug was this:
type BgImage = { toBlob: () => Promise<Blob> };
// The library's own types were loose, so this "tidied them up":
const segmenter = (await pipeline('background-removal', model)) as unknown as (
input: string,
) => Promise<BgImage[]>;
const output = await segmenter(url);
const blob = await output[0].toBlob(); // output[0] is undefined
The pipeline does not resolve to an array. It resolves to a single image object. So output[0] was undefined on every call, and .toBlob() threw on every call.
Read the cast again as an English sentence, because that is exactly what the compiler does with it:
“Trust me: calling this returns a promise for an array of objects that have
toBlob.”
That sentence was false. Nothing checked it. And from that line onward, tsc reasoned confidently about output using a shape that never existed at runtime — which is why the failure looked like a library problem rather than a typing problem.
Why it happens
as is not free-form. TypeScript will reject an assertion when the two types have no meaningful overlap:
const n = 42;
const s = n as string;
// Conversion of type 'number' to type 'string' may be a mistake because neither
// type sufficiently overlaps with the other. If this was intentional, convert
// the expression to 'unknown' first.
That error message is doing real work. It is the compiler saying your claim is implausible.
Now note what the message literally suggests, and what almost everyone does with it:
const s = n as unknown as string; // error gone. bug intact.
Every value is assignable to unknown, and unknown is assertable to anything. So the two-step always type-checks — for any pair of types. That is the point of the idiom, and also its entire danger: the check you silenced was the only automated opinion you were ever going to get about whether the shape is right.
| Form | Guardrail | Runtime check | Honest reading |
|---|---|---|---|
x as T | overlap check runs | none | ”narrow this, I know more than you” |
x as unknown as T | disabled | none | ”stop analysing, assume T” |
| type guard / validation | n/a | yes | ”verify it, then it is T” |
The reason this class of bug survives so long: a wrong assertion produces no error at the cast. It produces a wrong error, later, somewhere else. In our case the visible symptom was in .toBlob(), one line and one mental model away from the actual lie.
What to do
1. Handle the shape you are unsure about. The real fix was four characters of runtime logic, and it makes the code correct whether the library returns one image or many:
type BgImage = { toBlob: () => Promise<Blob> };
type Segmenter = (input: string) => Promise<BgImage | BgImage[]>;
const output = await segmenter(url);
const first = Array.isArray(output) ? output[0] : output;
if (!first) throw new Error('background removal returned no image');
const blob = await first.toBlob();
Array.isArray is a type guard, so inside each branch TypeScript knows the real type — no assertion needed anywhere.
2. Write your own guard when there is no built-in one. A function returning value is T converts a runtime check into type information:
function hasToBlob(v: unknown): v is { toBlob: () => Promise<Blob> } {
return typeof v === 'object' && v !== null && typeof (v as Record<string, unknown>).toBlob === 'function';
}
const output: unknown = await segmenter(url);
if (!hasToBlob(output)) throw new Error('unexpected pipeline output');
await output.toBlob(); // narrowed, not asserted
This is the honest version of the original cast. It says the same thing — but it checks, and it fails loudly at the boundary instead of silently three calls later.
3. Use satisfies when you want checking without widening. For values you own, satisfies validates against a type while keeping the literal type intact. It is the right tool for config objects, where people often reach for as:
const config = {
model: 'briaai/RMBG-1.4',
device: 'webgpu',
} satisfies { model: string; device: 'webgpu' | 'wasm' };
config.device; // type is 'webgpu', not the widened union — and a typo would error
4. Validate genuinely untrusted input at the boundary. For network responses and anything crossing a process line, a schema validator turns “I assume” into “I verified”:
import { z } from 'zod';
const Result = z.object({ url: z.string().url(), width: z.number().int() });
const data = Result.parse(await res.json()); // throws here, at the boundary
Where the real defence is
Everything above still only constrains what you write. The bug in the opening section could not have been caught by any amount of typing discipline alone, because the wrong assumption was about a third-party library’s runtime behaviour — and types are not evidence about runtime.
What actually caught it was running the feature in a real browser against a real input. That is the uncomfortable lesson: for a bug that lives between your code and a dependency, a green tsc is not evidence, and neither is a unit test that mocks the dependency (it would have happily mocked the wrong shape). The cheapest reliable check was one end-to-end test that loads the page, feeds it an image, and asserts something true about the output.
If you have exactly one as unknown as in a codebase, put the test there.
Caveats
- The idiom is not always wrong. Test doubles, deliberately partial mocks, and interop with untyped or badly-typed modules are legitimate uses. The rule is not “never” — it is never silently. Leave a comment naming the runtime fact you are asserting and how you verified it.
- Not every assertion is a double one.
as const, and narrowing aunknownyou have just validated, are fine and do not disable the overlap check. satisfiesis TypeScript 4.9+. On older versions, annotate the type explicitly instead.- A schema validator is a runtime dependency with a real cost — bundle size and parse time. Worth it at trust boundaries, overkill for internal function calls.
strictdoes not save you here. No compiler flag makes an assertion truthful;as unknown asopts out of exactly the analysis the flags turn on.
References
- TypeScript Handbook — Type Assertions — the “sufficiently overlaps” rule and the documented
unknownescape hatch - TypeScript 3.0 release notes — the
unknowntype - TypeScript Handbook — Narrowing and type predicates
- TypeScript 4.9 release notes — the
satisfiesoperator - Zod — schema validation
- Transformers.js documentation