Your types describe what your code expects. Data that arrives from outside, such as an HTTP response, a message from a queue, a file or localStorage, has never heard of them. TypeScript can’t check those values at compile time, because they don’t exist until the program runs. What it can do is make you check them. That’s what unknown is for, and using it at boundaries is one of the most practical habits a TypeScript developer can show in an interview.
any: the type that agrees with everything
Here’s a typical product loader:
type Product = { id: string; name: string; priceCents: number };
async function loadAny(id: string): Promise<Product> {
const res = await fetch(`/api/products/${id}`);
const body = await res.json(); // any
return body; // compiles, checks nothing
}It compiles under strict without complaint. res.json() is declared in the DOM library as returning Promise<any>, and any is assignable to every type. So the function claims to return a Product, and the compiler accepts that claim without evidence. JSON.parse returns any for the same reason: there’s no way to know statically what text you’ll parse.
The problem isn’t just that this one line is unchecked. any spreads. Every property you read from an any is also any, and every function you pass it to accepts it. The wrong value travels through the codebase looking like a valid Product until something finally misbehaves.
Suppose a backend change starts sending priceCents as a string for one product. The frontend sums the cart:
node -e '
const cart = [{ id: "p1", name: "Mug", priceCents: "1999" }, { id: "p2", name: "Pen", priceCents: 250 }];
console.log(cart.reduce((sum, p) => sum + p.priceCents, 0));'
# 019992500 + "1999" is string concatenation, so the total becomes "01999250". No exception, no type error, just a checkout page showing a price of nearly 20,000 dollars. The bug surfaces far from its cause, which is the worst kind to debug.
unknown: the type that agrees with nothing
unknown is the safe counterpart to any. Any value can be assigned to it, but you can’t do anything with it until you narrow it:
async function loadUnknown(id: string): Promise<Product> {
const res = await fetch(`/api/products/${id}`);
const body: unknown = await res.json();
return body;
}
function useIt(body: unknown) {
body.name;
const s: string = body;
}unk1.ts(12,3): error TS2322: Type 'unknown' is not assignable to type 'Product'.
unk1.ts(16,3): error TS18046: 'body' is of type 'unknown'.
unk1.ts(17,9): error TS2322: Type 'unknown' is not assignable to type 'string'.Annotating body as unknown costs nothing at runtime. It moves the gap from invisible to visible: the compiler now insists on a check before body can be returned as a Product. The code is honest about the fact that the network sent something you haven’t looked at yet.
Writing a type guard that holds up
The check has to happen at runtime, in JavaScript. A type guard packages it so the compiler can use the result:
function isProduct(value: unknown): value is Product {
return (
typeof value === "object" &&
value !== null &&
"id" in value && typeof value.id === "string" &&
"name" in value && typeof value.name === "string" &&
"priceCents" in value && typeof value.priceCents === "number" &&
Number.isInteger(value.priceCents)
);
}Each step narrows value a little further, and the compiler follows along. typeof value === "object" rules out primitives, but typeof null is also "object", so value !== null is required, and forgetting it is one of the classic mistakes. Since TypeScript 4.9, "id" in value narrows value to an object known to have an id property, so value.id can be read without a cast. The last line is a business rule rather than a type rule: cents should be whole numbers, so 19.99 is rejected even though it’s a number.
Here’s the guard run against a few payloads:
{"id":"p1","name":"Mug","priceCents":1999} true
{"id":"p1","name":"Mug","priceCents":"1999"} false
null false
[] false
{"id":"p1","name":"Mug","priceCents":19.99} falseThe string price from the earlier bug is now rejected at the door.
The return type value is Product is a promise to the compiler, and it doesn’t verify it. This compiles:
function bad(value: unknown): value is Product {
return typeof value === "object";
}Any object, and even null, now counts as a Product. So a type guard is trusted code. Keep each one small, test it with good and bad inputs as above, and don’t let it drift from the type when the type changes.
Validate once, at the edge
With the guard in place, the loader becomes:
async function load(id: string): Promise<Product> {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) throw new Error(`GET /api/products/${id} failed: ${res.status}`);
const body: unknown = await res.json();
if (!isProduct(body)) throw new Error(`Unexpected product payload for ${id}`);
return body;
}After isProduct(body) returns true, body is a Product, so the return compiles without a cast. Everything that calls load gets a value that was actually checked. That’s the pattern: one validation step where data enters, trusted types everywhere inside. Checking res.ok first matters too, because an error response often has a completely different JSON shape, and you want “the server returned 500”, not “unexpected payload”, in the logs.
Hand-written guards are fine for a few types. For a real API with dozens of shapes, a schema library (Zod, Valibot, ArkType and others) lets you declare the shape once and derive both the runtime validator and the TypeScript type from it, so the two can’t drift. The idea is the same; the library just writes the guard for you and produces better error messages, such as which field failed and why.
Where boundaries hide
HTTP responses are the obvious one. Others are easy to miss: JSON.parse of anything from localStorage (which another version of your app may have written), event.data from a postMessage or WebSocket, environment variables, URL query parameters, the payload of a queue message, rows from a database when the schema isn’t generated, and the catch variable, which strict already types as unknown for exactly this reason. In each case the honest type is unknown, and a check turns it into something useful.
Interview practice
A colleague says: “We control the backend and both sides share the same TypeScript types package, so validating API responses on the frontend is wasted effort. We can just write await res.json() as Product.” How do you respond?
Answer
Shared types help a lot, since they keep the two codebases agreeing at compile time, but they only describe what each side intends. They don’t guarantee what’s on the wire at runtime. A frontend and backend are deployed separately, so during a rollout an old client can talk to a new server or the reverse. Proxies, CDNs and auth gateways can return their own HTML or JSON error pages. A database migration can leave old rows with null in a field the type calls string. In each of those cases as Product claims the shape is right without checking, and the failure shows up later as something like the "01999250" total rather than at the point where the bad data arrived.
So I’d still type res.json() as unknown and validate at that one boundary. The cost is small: a guard or a schema per endpoint and a few microseconds per response. I’d suggest deriving the shared types from schemas, so the backend and frontend share the validator as well as the type. That removes the drift argument entirely, and when something does go wrong, the error says which endpoint and which field.