Absorbing unknown Into the Type Realm
March 7, 2026
Every as in your TypeScript is a tiny lie. Sometimes a necessary one, but a lie nonetheless — you're telling the compiler "trust me" about data it can't verify. The question is: when can you avoid it?
I just went through a codebase audit where I enabled no-unsafe-type-assertion and had to deal with every single as cast. Here's what I learned about the different shapes of unknown and what to do with each.
Foreign JSON: parse it
The most common source of unknown is response.json(). It returns Promise<any>, and the instinct is:
const user = (await res.json()) as User;This is the worst kind of lie — you're asserting the shape of data from across a network boundary. The fix is obvious: validate it.
import { z } from "zod";
const userSchema = z.object({
id: z.number(),
login: z.string(),
name: z.string().nullable(),
});
const user = userSchema.parse(await res.json());Now you have runtime proof that the data matches. If the API changes shape, you get a clear error instead of undefined is not a function three stack frames later.
For frequently-called endpoints, wrap it in a function:
async function fetchUser(token: string) {
const res = await fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
return userSchema.parse(await res.json());
}Or better — if you use a Result type (I use better-result), you can chain validation into a pipeline instead of scattering try/catch:
const result = await safeFetchJson("https://api.github.com/user", {
headers: { Authorization: `Bearer ${token}` },
});
return result.andThen(safeZodParse(userSchema)).unwrap("GitHub API error");A Result<T, E> is either Ok holding a value or Err holding a typed error — it makes failure explicit in the return type instead of hiding it in exceptions. .andThen() chains operations that might fail (like zod parsing after a fetch), and .unwrap() extracts the value or throws. The safeZodParse is curried, so it slots right into andThen. Fetch, validate, extract — one line, left to right.
Generic boundaries: one cast to rule them all
Sometimes you're writing a utility that's generic over T, and you need exactly one as cast at the boundary. Here's what safeFetchJson looks like under the hood — it wraps fetch and returns a Result so callers never see any:
async function safeFetchJson<T>(
input: URL | string,
init?: RequestInit,
): Promise<Result<T, FetchJsonError>> {
const fetchResult = await safeFetch(input, init);
if (fetchResult.isErr()) return fetchResult;
const response = fetchResult.value;
if (!response.ok) {
return Result.err(new HttpError({ status: response.status, /* ... */ }));
}
return Result.tryPromise({
try: () => response.json() as Promise<T>, // the one cast
catch: (cause) => new ParseError({ cause }),
});
}Result.tryPromise catches rejected promises and wraps them in Err — no try/catch in sight. The single as Promise<T> is the boundary between any (what response.json() returns) and the typed world. The caller is responsible for validating T:
const result = await safeFetchJson(url);
return result.andThen(safeZodParse(mySchema));One as in the utility, zero in application code. That's the right trade.
Type guards: teach the compiler what you know
When you've already checked a condition but TypeScript can't narrow the type, write a type guard:
// Before: cast after manual check
if (node.type === "image") {
const imageNode = node as ImageNode;
// use imageNode.url
}
// After: type guard encapsulates the check
function isImageNode(node: { type: string }): node is ImageNode {
return node.type === "image";
}
if (isImageNode(node)) {
// node.url is available, no cast needed
}This works well for:
AST nodes where
.typediscriminates but the union is too wide for TS to narrowString unions where a value comes from an untyped source (DOM events,
parseArgs)Set membership checks:
type ColorKey = "color0" | "color1" | /* ... */ | "background";
const colorKeys = new Set<string>(["color0", "color1", /* ... */]);
function isColorKey(key: string): key is ColorKey {
return colorKeys.has(key);
}The guard is reusable and keeps the as out of your application logic.
Assertion functions: guards that throw
Type guards return a boolean and narrow inside an if block. Assertion functions throw on failure and narrow for the rest of the scope:
// Type guard — narrowing inside the if block
function isString(value: unknown): value is string {
return typeof value === "string";
}
if (isString(x)) {
x.toUpperCase(); // narrowed here
}
// x is back to unknown here
// Assertion function — narrowing from this point forward
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
assertIsString(x);
x.toUpperCase(); // narrowed for the rest of the scopeAssertion functions are great for preconditions — validate once at the top of a function, then work with the narrowed type for the rest of the body. They reduce nesting compared to if (isX(val)) { ... } chains.
That said, for external data I still prefer zod. Assertion functions shine for internal invariants: "this should always be a string at this point, and if it's not, something is very wrong."
The catch clause: unknown's most annoying appearance
catch (e) gives you unknown (or any in older configs). The instinct:
catch (err) {
setError((err as Error).message);
}If err isn't an Error — say, someone threw a string or a number — this blows up. The fix:
catch (err) {
setError(err instanceof Error ? err.message : String(err));
}instanceof is the type guard here. It narrows unknown to Error in the truthy branch, and String() handles everything else. No cast needed.
ORM JSON columns: type at the schema level
ORMs store JSON as text and return it as unknown. If you're casting after every query, fix it at the source.
Drizzle has $type<T>():
const Theme = sqliteTable("theme", {
colors: text("colors", { mode: "json" })
.notNull()
.$type<{
foreground: OklchColor;
background: OklchColor;
// ...
}>(),
});Now every query returns colors as the typed object. Zero casts in application code.
Prisma has a similar pattern with Json fields and generated types. The point is: push the type information to the schema definition, not to every call site.
Proxy targets: accept the lie
Sometimes there's no way around it:
export const db = new Proxy({} as Database, {
get(_, prop) {
return getRealDb()[prop as keyof Database];
},
});The {} as Database is a lie — that empty object isn't a Database. But the Proxy intercepts every access, so the empty object is never touched. There's no data to validate, no foreign input to parse. The cast is a structural necessity of the Proxy pattern.
Same with lazy environment variables:
export const env = new Proxy({} as Env, {
get(_, prop: string) {
return getValidatedEnv()[prop as keyof Env];
},
});These get inline disable comments and that's fine.
Generated / vendor code: file-level disable
If you're using copied UI components (shadcn-style), they often have casts internally. Don't fight them — they'll get overwritten on the next update.
// oxlint-disable typescript-eslint/no-unsafe-type-assertionAt the top of the file. Move on.
The hierarchy
When unknown data arrives at your boundary:
Can you validate it? Use zod (or any schema library). This is the best option for JSON from APIs, databases, localStorage, URL params.
Can you narrow it? Write a type guard or assertion function. Best for discriminated unions, string literals, set membership, preconditions.
Is it a generic boundary? One
asin the utility, validation at the call site.Is it structural plumbing? (Proxies, generated code) Inline disable, move on.
The goal isn't zero as — it's zero unexamined as. Every cast should be a conscious decision, not a reflex.