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 .type discriminates but the union is too wide for TS to narrow

  • String 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 scope

Assertion 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-assertion

At the top of the file. Move on.

The hierarchy

When unknown data arrives at your boundary:

  1. Can you validate it? Use zod (or any schema library). This is the best option for JSON from APIs, databases, localStorage, URL params.

  2. Can you narrow it? Write a type guard or assertion function. Best for discriminated unions, string literals, set membership, preconditions.

  3. Is it a generic boundary? One as in the utility, validation at the call site.

  4. 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.

Comments 0

No comments yet. Be the first to comment!