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 isvalue is ApiUser, notboolean— that's what makes it a type predicate rather than an ordinary boolean function. Everywhere this function returnstrue, TypeScript narrows the checked value toApiUser.- The body does the actual runtime verification: confirms
valueis a non-null object, then checks each expected field individually withtypeof. parseUsercallsisApiUserinside anif, and returnsraw— by the time thatreturnexecutes,rawhas been narrowed fromunknowntoApiUser, 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 — checkingidbut forgettingemail, 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>).idneeds that intermediate cast because indexing into anunknownvalue is itself disallowed — you have to widen just enough to check a specific key without widening all the way toany.
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.