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(Error.isError(err) ? err.message : String(err));
}

Error.isError() is the type guard here — it narrows unknown to Error in the truthy branch, and String() handles everything else. No cast needed. Unlike instanceof, it works across realms (iframes, workers, vm contexts) and is now supported in all major browsers.

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!