The boundary problem
Everything so far checks code you wrote. But data crossing into your program is typed by nobody: a web API response sent by another server (lesson 2-3), a file on disk, raw user input. JSON.parse returns any by design, and lesson 2-3 warned you where any leads.
The professional pattern has three steps:
- Declare an interface for the shape you expect
- Hold the parsed value as
unknown, notany - Write a checking function that proves the shape at runtime, then use the data fully typed
Step 3 uses a type predicate, a return type of the form value is ApiUser. It tells the compiler that when this function returns true, the argument should be treated as that type from there on. It is narrowing (Unit 4) that you define yourself.
as: the assertion inside the checker
One new keyword appears in the checking function below: value as { id?: unknown }. The as keyword is a type assertion, telling the compiler to treat this value as this type, with no runtime check and no conversion. The compiler simply believes you.
That makes as dangerous as a general tool. Assert the wrong type and you have re-created the any problem with extra steps, a claim the running program can still violate.
The legitimate, narrow use is exactly what the checker does. After proving value is a non-null object, it asserts a shape whose properties are all typed unknown, so nothing becomes trusted, and each property must still pass its own typeof test before use. The assertion claims no more than "an object whose properties I am about to check".
That is the rule for as in general: use it only when you know something the compiler cannot, and keep the claim as weak as possible.
A checked API payload, end to end
This puts all three steps together: an interface for the expected shape, an unknown holding the parsed value, and a type predicate that proves the shape before use.
interface ApiUser { id: number; name: string; email: string; } const raw = '{"id": 1, "name": "Ada", "email": "ada@example.com"}'; const data: unknown = JSON.parse(raw); function isApiUser(value: unknown): value is ApiUser { if (typeof value !== "object" || value === null) { return false; } const v = value as { id?: unknown; name?: unknown; email?: unknown }; return typeof v.id === "number" && typeof v.name === "string" && typeof v.email === "string"; } if (isApiUser(data)) { console.log(data.name + " <" + data.email + ">"); } else { console.log("bad payload"); }
Output
Ada <ada@example.com>
Following the narrowing
- Inside the
if,datais anApiUserand every property is typed. Outside it,datais stillunknown, so the else branch cannot accidentally readdata.name. - The
askeyword appears once, and only to inspect properties of a value already proven to be a non-null object. Every property it exposes is typedunknown, so nothing is trusted yet. - Change
"id": 1to"id": "1"in the raw string and the check returns false, printingbad payload. The bad data is caught at the boundary rather than surfacing as a strange result three functions later.
Why the parsed value starts as unknown
Typing the JSON.parse result as ApiUser directly would be a promise the server might break, while unknown forces an actual runtime check first.
The core point is that annotations are compile-time claims. They do not inspect real data, and they cannot. Writing const data: ApiUser = JSON.parse(raw) compiles without complaint no matter what the server actually sends, because there is nothing for the compiler to verify against.
The failure mode that follows is the expensive kind. A server bug or an API change ships, your code reads data.name.toUpperCase() on a value that is missing name, and the crash happens deep inside your program, far from the boundary where the bad data entered.
unknown makes the compiler refuse to touch the value until a runtime check proves the shape. The check has to exist, and it runs at the edge of the program, which is exactly the boundary discipline production apps use.
isProduct: the same pattern with two properties
Product has a name and a price, and isProduct proves a parsed value matches before anything reads from it.
interface Product { name: string; price: number; } const raw = '{"name": "Keyboard", "price": 49}'; const data: unknown = JSON.parse(raw); function isProduct(value: unknown): value is Product { if (typeof value !== "object" || value === null) { return false; } const v = value as { name?: unknown; price?: unknown }; return typeof v.name === "string" && typeof v.price === "number"; } if (isProduct(data)) { console.log(data.name + " costs $" + data.price); } else { console.log("bad payload"); }
Output
Keyboard costs $49Reading the checker
- It mirrors
isApiUserfrom the previous example, with two properties instead of three. The structure is a template you will reuse for every shape that crosses a boundary. - The first guard rejects non-objects and
null. Thenullcase needs its own test becausetypeof nullis"object", a long-standing JavaScript quirk. - The final
returnis onetypeofcheck per property, joined with&&, so all of them must pass. - The predicate return type
value is Productis what letsdata.nametype-check inside theif. Change it to plainbooleanand the body of theifstops compiling, even though the runtime behaviour would be identical.