~/blog

as unknown as: the double cast that hides real bugs

published

#typescript#types#debugging

A glowing chain on a black background, snapped clean through the middle: the left half glows neon green and the right half hot pink, with grey anchor plates at both ends.

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.

FormGuardrailRuntime checkHonest reading
x as Toverlap check runsnone”narrow this, I know more than you”
x as unknown as Tdisablednone”stop analysing, assume T
type guard / validationn/ayes”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

References