10LOC
TypeScriptintermediate

unknown plus a type guard instead of any for safe parsing

Published February 2, 2027

type ApiUser = { id: string; email: string };

const isApiUser = (value: unknown): value is ApiUser =>
  typeof value === "object" &&
  value !== null &&
  typeof (value as Record<string, unknown>).id === "string" &&
  typeof (value as Record<string, unknown>).email === "string";

export const parseUser = (raw: unknown): ApiUser => {
  if (!isApiUser(raw)) throw new TypeError("Expected an ApiUser shape");
  return raw;
};

What

parseUser takes unknown — the honest type of whatever JSON.parse hands back — and only returns it as ApiUser after isApiUser, a type predicate, has actually checked its shape at runtime.

Why it matters

JSON.parse's return type is any, which is easy to reach for and immediately dangerous: any disables type checking on everything derived from that value, so a typo like user.emial compiles fine and fails at runtime, right alongside the "expected" case of the server actually sending malformed data. Typing the input as unknown instead keeps the compiler honest — you can't call .email on an unknown value until you've proven what it is. A type guard is how you supply that proof: a plain boolean-returning function tells the compiler that something was checked, but not what; a type predicate (value is ApiUser) tells it exactly which type the check narrows to.

How it works

  • isApiUser's return type is value is ApiUser, not boolean — that's what makes it a type predicate rather than an ordinary boolean function. Everywhere this function returns true, TypeScript narrows the checked value to ApiUser.
  • The body does the actual runtime verification: confirms value is a non-null object, then checks each expected field individually with typeof.
  • parseUser calls isApiUser inside an if, and returns raw — by the time that return executes, raw has been narrowed from unknown to ApiUser, no cast required.

Gotchas

  • A type guard is a promise you're making to the compiler, not a verification the compiler double-checks. If isApiUser's body is wrong — checking id but forgetting email, say — TypeScript trusts it anyway, and the bug moves from "compile error" to "silent bad data," which defeats the purpose.
  • typeof (value as Record<string, unknown>).id needs that intermediate cast because indexing into an unknown value is itself disallowed — you have to widen just enough to check a specific key without widening all the way to any.

Related

  • Assertion functions (asserts value is T) — the same underlying idea, phrased as "throw if wrong" instead of "return true/false"; better suited to call sites that want to fail loudly rather than branch.
  • Branded types — a compile-time-only distinction; type guards are the runtime counterpart, for input that genuinely might not match at all.