Phase 6 · Utility types in practice · Lesson 6.3
AdvancedTyping data at the boundary
Why JSON.parse returns any, how unknown and hand-written type guards keep untrusted data honest, and how to infer a TypeScript type from a tiny runtime schema.
25 min
TypeScript checks your code, not your data. The moment a value comes from outside the program (an API response, localStorage, a form, a message from another window) the compiler has no idea what it is. Whatever type you write there is a promise you make, not a fact the compiler checked. This lesson is about keeping that promise: checking data once, at the edge, so the rest of the program can trust its types.
JSON.parse returns any, and any spreads
Look at the signature in lib.es5.d.ts:
parse(text: string, reviver?: (this: any, key: string, value: any) => any): any;any switches the type checker off. Every property access, call and assignment on it is allowed, and the result is any too:
type User = { id: number; name: string };
const data = JSON.parse('{"id": 1, "nmae": "Ada"}');
// ^? const data: any
const shout = data.name.toUpperCase(); // compiles, crashes at runtime: data.name is undefined
// ^? const shout: any
const user: User = data; // compiles: any is assignable to everything
user.name.length; // TypeScript is now sure this is a string. It isn't.That last line is the real danger. any doesn't stay where it came from: assign it to a typed variable and the lie spreads with a perfectly respectable type attached. The crash happens far from the place that caused it. Response.json() has the same problem: it returns Promise<any>.
Use unknown for untrusted input
unknown is the safe counterpart of any. Anything can be assigned to it, but you can't do anything with it until you've narrowed it.
function parseJson(text: string): unknown {
return JSON.parse(text);
}
const data = parseJson('{"id": 1}');
// @ts-expect-error -- 'data' is of type 'unknown'.
data.id;
if (typeof data === "object" && data !== null && "id" in data) {
data.id; // allowed: data is object & Record<"id", unknown>
// ^? unknown
}Wrapping JSON.parse like this costs one line and forces every caller to check. Since TypeScript 4.9, "key" in value narrows an object to one that has that key (with type unknown), which makes hand-written checks much less painful.
Quick check
Which line is a compile error?
let value: unknown = "hello"; // A
value = { nested: true }; // B
value.toString(); // C
const same = value === "hello"; // DValidators as type guards
A type guard is a function returning value is T. When it returns true, the compiler narrows the argument to T.
type User = { id: number; name: string; email?: string };
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "number" &&
"name" in value &&
typeof value.name === "string" &&
(!("email" in value) || value.email === undefined || typeof value.email === "string")
);
}
const data: unknown = JSON.parse('{"id": 1, "name": "Ada"}');
if (isUser(data)) {
data.name.toUpperCase(); // data: User
}Because type guards are how you'll most often cross the boundary, you should know their big weakness: the compiler trusts the body blindly. It checks that you return a boolean, not that the boolean is correct.
type User = { id: number; name: string };
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null; // wrong, but compiles
}
const data: unknown = { id: "not a number" };
if (isUser(data)) {
data.id.toFixed(); // compiles, crashes at runtime
}A type guard is a tiny as cast with a runtime check attached. Keep them small, test them, and avoid writing value is T for a T bigger than you can check.
Assertion functions
An assertion function throws instead of returning false. After the call, the argument is narrowed for the rest of the scope:
type User = { id: number; name: string };
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null &&
"id" in value && typeof value.id === "number" &&
"name" in value && typeof value.name === "string";
}
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) throw new TypeError("Expected a User");
}
const data: unknown = JSON.parse('{"id": 1, "name": "Ada"}');
assertIsUser(data);
data.name; // data: User from here onParse, don't validate
A validator answers yes or no, and the program has to remember what the answer was. A parser turns unstructured input into a typed value, or fails. The difference shows up in function signatures:
type User = { id: number; name: string };
// Validate: the check and the use are separate. Nothing stops you from skipping the check.
function isValidUser(value: unknown): boolean {
return typeof value === "object" && value !== null && "id" in value && "name" in value;
}
// Parse: the only way to get a User is to go through here.
function parseUser(value: unknown): User {
if (typeof value !== "object" || value === null) throw new TypeError("Expected an object");
if (!("id" in value) || typeof value.id !== "number") throw new TypeError("id: expected number");
if (!("name" in value) || typeof value.name !== "string") throw new TypeError("name: expected string");
return { id: value.id, name: value.name };
}
function greet(user: User) {
return `Hello, ${user.name}`;
}
const raw: unknown = JSON.parse('{"id": 1, "name": "Ada", "isAdmin": true}');
greet(parseUser(raw));Three benefits:
- Downstream code takes
User, notunknown. It can't be called with unchecked data, so it can't forget to check. - The result is a fresh object.
parseUsercopies only the known fields, so the unexpectedisAdmindoesn't sneak through. - Errors are specific.
"id: expected number"beatsfalse.
The rule of thumb: parse at the edge (right after fetch, JSON.parse or reading storage), then pass typed values inward. The core of your program never sees unknown.
A tiny schema builder
Writing parseUser by hand for every type gets repetitive, and you have to keep the parser and the type in sync. Libraries solve this with schemas: you describe the shape once, as a runtime value, and derive the TypeScript type from it. The trick fits in a few dozen lines, and it's a great exercise in generics and inference.
The core idea: a schema is an object with a parse method, and the type it produces is carried in a type parameter.
type Schema<T> = { parse(input: unknown, path?: string): T };
type Infer<S> = S extends Schema<infer T> ? T : never;
class ParseError extends Error {}
const string = (): Schema<string> => ({
parse(input, path = "value") {
if (typeof input !== "string") throw new ParseError(`${path}: expected string`);
return input;
},
});
const number = (): Schema<number> => ({
parse(input, path = "value") {
if (typeof input !== "number" || Number.isNaN(input)) throw new ParseError(`${path}: expected number`);
return input;
},
});
function array<T>(item: Schema<T>): Schema<T[]> {
return {
parse(input, path = "value") {
if (!Array.isArray(input)) throw new ParseError(`${path}: expected array`);
return input.map((element, i) => item.parse(element, `${path}[${i}]`));
},
};
}
type Shape = Record<string, Schema<unknown>>;
type InferShape<S extends Shape> = { [K in keyof S]: Infer<S[K]> };
function object<S extends Shape>(shape: S): Schema<InferShape<S>> {
return {
parse(input, path = "value") {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new ParseError(`${path}: expected object`);
}
const record = input as Record<string, unknown>; // safe: checked just above
const out: Record<string, unknown> = {};
for (const key of Object.keys(shape)) {
out[key] = shape[key].parse(record[key], `${path}.${key}`);
}
return out as InferShape<S>; // safe: every key was parsed by its schema
},
};
}
// ---- Usage: describe once, get the type for free ----
const User = object({
id: number(),
name: string(),
tags: array(string()),
address: object({ city: string() }),
});
type User = Infer<typeof User>;
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type _t1 = Expect<Equal<User, { id: number; name: string; tags: string[]; address: { city: string } }>>;
const user = User.parse(JSON.parse('{"id": 1, "name": "Ada", "tags": [], "address": {"city": "London"}}'));
user.address.city.toUpperCase(); // fully typed▶ Try it in the TypeScript Playground
How the types flow:
number()returnsSchema<number>, so its type carriesnumber.object({ id: number(), ... })infersSas{ id: Schema<number>; ... }from the argument.InferShape<S>maps overS's keys and usesInfer(aninferconditional) to pull out each field's type.const User(a value) andtype User(a type) can share a name, because values and types live in separate namespaces.typeof Useris the schema's type;Inferturns it into the data type.
The two as casts are the only places we "trust" something, and each is justified by the check right above it. Everything else is inferred. Production libraries add optional fields, unions, better error collection and more, but the inference trick is exactly this one.
Quick check
With the schema builder above, what is T?
declare function boolean(): Schema<boolean>;
const Flags = object({ ok: boolean() });
type T = Infer<typeof Flags>;satisfies: typed config without losing detail
Data you write yourself (config objects, route tables, lookup maps) doesn't need runtime checks, but it has the opposite problem: a type annotation throws away detail.
type Route = { path: string; auth: boolean };
const routes: Record<string, Route> = {
home: { path: "/", auth: false },
settings: { path: "/settings", auth: true },
};
routes.setings; // typo compiles: any string key is allowed
// ^? RouteThe satisfies operator (TypeScript 4.9) checks that a value matches a type without changing the value's inferred type:
type Route = { path: string; auth: boolean };
const routes = {
home: { path: "/", auth: false },
settings: { path: "/settings", auth: true },
} satisfies Record<string, Route>;
routes.settings.path; // fine
// @ts-expect-error -- Property 'setings' does not exist on type '{ home: { path: string; auth: false; }; settings: { path: string; auth: true; }; }'.
routes.setings;
type RouteName = keyof typeof routes;
// ^? "home" | "settings"
const broken = {
// @ts-expect-error -- Object literal may only specify known properties, and 'auht' does not exist in type 'Route'.
home: { path: "/", auht: false },
} satisfies Record<string, Route>;You get both: the object is checked against Route (including excess-property typos), and the keys stay precise, so keyof typeof routes is a useful union. Notice the auth values were inferred as the literals false and true: satisfies provides a contextual type, which keeps literal types where the target type is a literal union like boolean.
const x: T = value | const x = value satisfies T | const x = value as T | |
|---|---|---|---|
Checks value against T | Yes | Yes | Only loosely (allows narrowing casts) |
Type of x | T | Inferred from value | T |
| Safe for untrusted data | No | No | No |
None of the three checks anything at runtime. For data from outside, you still parse.
Catch variables are unknown too
A throw can throw anything: an Error, a string, undefined, a promise. Since TypeScript 4.4, the useUnknownInCatchVariables flag (part of strict) types the catch variable as unknown instead of any. It's the same boundary idea: an exception is data you didn't produce.
try {
JSON.parse("{ not json");
} catch (error) {
// ^? unknown
// @ts-expect-error -- 'error' is of type 'unknown'.
console.log(error.message);
if (error instanceof Error) console.log(error.message); // narrowed: fine
}You can write catch (error: unknown) explicitly, or catch (error: any) to opt out, but no other annotation is allowed. catch (error: Error) is an error, because nothing guarantees it. The next lesson covers narrowing errors properly.
Spot the error
This compiles. What's wrong with it, and how do you fix it?
type Product = { id: number; price: number };
async function loadProducts(): Promise<Product[]> {
const res = await fetch("/api/products");
return res.json();
}
const total = (await loadProducts()).reduce((sum, p) => sum + p.price, 0);Show the answer
res.json() returns Promise<any>, and any is assignable to Product[], so the return type is unchecked. If the API sends { "items": [...] } or prices as strings, total silently becomes NaN or a concatenated string like "012.5", and no error points at the cause. Treat the body as unknown and parse it:
type Product = { id: number; price: number };
function parseProduct(value: unknown, i: number): Product {
if (
typeof value === "object" && value !== null &&
"id" in value && typeof value.id === "number" &&
"price" in value && typeof value.price === "number"
) {
return { id: value.id, price: value.price };
}
throw new TypeError(`products[${i}]: expected { id: number, price: number }`);
}
async function loadProducts(): Promise<Product[]> {
const res = await fetch("/api/products");
const body: unknown = await res.json();
if (!Array.isArray(body)) throw new TypeError("products: expected array");
return body.map(parseProduct);
}Annotating const body: unknown is the key move: it stops any from spreading and makes the compiler demand a check.
Recap
- Types describe your code, not your data. Anything from outside is unverified.
JSON.parseandResponse.json()returnany, which disables checking and spreads into typed variables.- Annotate untrusted input as
unknown; narrow withtypeof,in,Array.isArray,instanceof. - Type guards (
value is T) and assertion functions (asserts value is T) narrow, but the compiler trusts their bodies. - Parse, don't validate: turn
unknowninto a typed value once at the edge; core code accepts only parsed types. - A schema builder carries the output type in
Schema<T>;Infer<typeof Schema>derives the TS type, so type and check can't drift. satisfieschecks a value against a type while keeping its precise inferred type.useUnknownInCatchVariables(instrict) makescatch (e)unknown; onlyunknownoranyannotations are allowed.
Interview cards
1 / 7