JSON, DTOs, and Runtime Validation for API Types
Understand that TypeScript's types disappear entirely at runtime, and learn how to safely handle JSON from an API using unknown, DTOs for requests/responses/errors, runtime validation, and the distinction between static types and runtime schemas when building a type-safe API client.
TypeScript types are a compile-time-only concept — they are checked while you write code and then completely erased before your program runs. Nowhere is this more consequential than at the boundary between your program and the outside world: any data that arrives over the network as JSON has no type information at all until you decide how to trust it.
Why TypeScript types vanish at runtime
When TypeScript compiles to JavaScript, every type annotation, interface, and type alias is stripped out — function greet(name: string) becomes just function greet(name) in the emitted code. This is why typeof someValue === "User" is not something you can write: User never existed as a runtime concept to begin with, only as a compile-time constraint the checker enforced against your source.
This erasure is precisely why casting an API response to a type (const user = await response.json() as User) is dangerous rather than merely optimistic: the as User doesn't check anything, transform anything, or validate anything — it purely tells the compiler 'stop checking this value against anything else, trust me it's a User.' If the server actually sends { "id": "abc" } where id should be a number, the cast compiles cleanly and the bug surfaces later, wherever the code finally does id + 1 and gets "abc1" instead of a runtime error pointing at the source.
The practical consequence: type annotations describe your intent and assumptions, but only runtime code — an if check, a validation library, a parser — can actually enforce that a piece of data arriving from outside your program (JSON from a server, localStorage, a file, user input) really matches what you claimed. Treating a network response as automatically typed is one of the most common sources of runtime bugs in TypeScript codebases that otherwise look fully type-safe.
DTOs: request, response, and error response types
A DTO (Data Transfer Object) is a type describing the exact shape of data crossing a boundary — typically the JSON body of an HTTP request or response. Naming these deliberately (CreateUserRequest, UserResponse, ApiErrorResponse) rather than reusing your internal domain types keeps the API's actual wire contract separate from how your application chooses to model that data internally, which matters because the two often diverge — a server might send created_at as an ISO string while your app prefers a Date object.
A request DTO describes what you send: typically a plain object matching exactly what the server's endpoint expects, sometimes narrower than your full domain type (a CreateUserRequest might omit id, which the server assigns). A response DTO describes exactly what the server sends back, in its raw wire shape — string dates, snake_case field names, nullable fields the server actually allows to be null — before any transformation your app applies afterward.
An error response type deserves its own explicit shape too, since APIs are rarely one-size-fits-all about failure: type ApiErrorResponse = { error: string; code: string; fields?: Record<string, string> }; lets calling code handle a validation failure differently from a generic server error, with the compiler enforcing that every field access on an error response is checked against what the API actually promises to send back on failure.
unknown and runtime validation before trusting JSON
The honest type for JSON.parse(text) and response.json() is unknown — you genuinely don't know the shape until you check it, and unknown forces exactly that discipline: you cannot access any property on an unknown value without first narrowing it, unlike any, which lets unchecked property access through silently.
Runtime validation is the bridge from unknown to a trusted, specific type: a validation function (hand-written, or via a schema library) inspects the actual value at runtime and either returns it as the narrowed type or reports/throws a validation error. A hand-written type guard might look like function isUser(v: unknown): v is User { return typeof v === "object" && v !== null && typeof (v as any).id === "number"; } — deliberately checking every field the type promises, not just assuming the shape.
In practice, most real projects use a schema validation library (Zod, Valibot, io-ts, and similar) rather than hand-writing every guard, because a schema defines the shape once and can both validate at runtime and generate the exact matching static type, keeping the two permanently in sync instead of risking them drifting apart as the API evolves.
Building a type-safe API client with a validation layer
A type-safe API client wraps every request behind a function whose signature promises a specific, trusted return type, and internally performs the fetch, the JSON parse, and the runtime validation as one atomic step — so nothing outside that function ever handles unvalidated data. Callers get a Promise<User> (or a typed error) with confidence the validation actually happened, not just a type annotation asserting it did.
The core idea is a pipeline every piece of external data passes through, in order: the server sends raw JSON text, which is genuinely unknown the moment it lands in your program; runtime validation checks it against an expected shape; only data that passes becomes 'trusted,' and only then is it safe to treat as your application's specific domain type, ready to flow through the rest of the type-checked codebase with real guarantees behind the type annotations.
This distinction — a static type (compile-time only, erased, zero runtime cost, purely documentation-and-checking for your own code) versus a runtime schema (an actual value your program inspects, has a real runtime cost, and can reject bad data) — is the single most important mental model for handling any data that crosses outside your program's own control. TypeScript alone secures the inside of your program; validation secures the boundary.
DTOs and a validated fetch helper
UserResponse intentionally keeps created_at as a raw string — it describes exactly what the wire format is, not what you'd prefer to work with internally. isUserResponse is a user-defined type guard (v is UserResponse) — TypeScript narrows any unknown value to UserResponse inside a block where this function returns true.
type CreateUserRequest = {
name: string;
email: string;
};
type UserResponse = {
id: number;
name: string;
email: string;
created_at: string; // raw wire shape: ISO string, not Date
};
type ApiErrorResponse = {
error: string;
code: string;
};
function isUserResponse(v: unknown): v is UserResponse {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return (
typeof o.id === "number" &&
typeof o.name === "string" &&
typeof o.email === "string" &&
typeof o.created_at === "string"
);
}Code Examples
as User compiles without complaint no matter what the server actually returns, because a type assertion performs zero runtime verification. If the API's id field is ever a string (a common real-world mismatch, especially with databases that use string IDs), this code silently produces wrong results instead of failing loudly at the boundary where the bad data actually entered.
Common mistake: The bug this produces (string concatenation instead of numeric addition) surfaces far from its actual cause — wherever user.id happens to be used arithmetically — making it much harder to debug than a validation error raised right at the fetch call.
data starts as unknown — completely untrusted — and is only returned as UserResponse after isUserResponse has actually inspected its fields at runtime. A malformed response throws a clear, specific error right where the bad data entered the program, instead of silently propagating a wrong shape deeper into the app.
if (!isUserResponse(data)) {
type User = { id: number; name: string };
async function getUserUnsafe(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data as User; // no validation at all
}
const user = await getUserUnsafe(1);
console.log(user.id + 1); // compiles fine, but if the server sent id as a string, this is "11" not 2async function getUserSafe(id: number): Promise<UserResponse> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
const errorBody: unknown = await response.json().catch(() => null);
const message =
errorBody && typeof errorBody === "object" && "error" in errorBody
? String((errorBody as ApiErrorResponse).error)
: `HTTP ${response.status}`;
throw new Error(message);
}
const data: unknown = await response.json();
if (!isUserResponse(data)) {
throw new Error("Received malformed user data from server");
}
return data; // now genuinely, verifiably a UserResponse
}From raw server bytes to a trusted application type
Only the runtime validation step actually inspects the data — everything to its right is a type-level promise that's only as trustworthy as the check that produced it.
Server JSON -> Unknown Data -> Runtime Validation -> Trusted Data -> Application Type
(bytes) (type: unknown) (schema/type guard) (verified) (User, Order, ...)Important Rules
Never treat the result of response.json() or JSON.parse() as already matching your expected type — its real, honest type is unknown until something actually checks it.
Give request bodies, response bodies, and error responses their own explicitly named DTO types that mirror the wire format exactly, rather than reusing internal domain types for the network boundary.
Prefer a user-defined type guard or a schema validation library over a bare as cast whenever data originates outside your program's own control.
Keep a schema (runtime) and its corresponding static type in sync deliberately — a schema library that derives the static type from the schema definition avoids the two drifting apart as an API evolves.
Handle the error response shape as its own explicit type, not as an afterthought — most real APIs have a distinct, structured error format worth modeling.
Where this shows up
Every production application that talks to a backend — a REST API, a GraphQL endpoint returning JSON, a third-party API integration — needs this pipeline somewhere. Teams typically centralize it in an API client layer built on a schema library (Zod is especially common in the TypeScript ecosystem) so that every endpoint's request and response shape is defined once, validated automatically, and the corresponding static types are derived from that same definition rather than maintained by hand in two places.
const data = await response.json() as User[];
const data: unknown = await response.json(); if (!Array.isArray(data) || !data.every(isUser)) { throw new Error("unexpected response shape"); } // data is now User[]
Casting straight to User[] provides zero runtime guarantee that the response is even an array, let alone that every element matches User — a genuinely malformed or changed API response would pass compilation and fail somewhere far less obvious.
type UserResponse = User; // reusing the internal domain type as the wire type type UserResponse = { id: number; name: string; created_at: string }; type User = { id: number; name: string; createdAt: Date };
Collapsing the wire format and the internal domain model into one type hides the transformation that actually has to happen (string to Date, snake_case to camelCase) and makes it easy to forget that transformation altogether, or to silently break it when the API's raw format changes.
[multiple-choice] What is the actual, honest compile-time type of await response.json() before any validation? A. any, always safe to use directly B. unknown — nothing is known about its shape until it's checked C. The type you last annotated a variable with D. object, guaranteed to have at least one property
B. unknown — nothing is known about its shape until it's checked — response.json() returns a Promise<any> by the DOM lib's typing, but the honest, safe way to treat that value is as unknown — its real shape depends entirely on what the server actually sent, which TypeScript has no way to know at compile time.
[true-false] Writing const user = data as User; performs a runtime check to confirm that data actually matches the shape of User. A. True B. False
B. False — A type assertion (as) is purely a compile-time instruction to the type checker and performs no runtime verification whatsoever — if data doesn't actually match User, the mismatch is not caught here and can cause bugs later.
[multiple-choice] Why should response DTOs often be kept separate from internal domain types, rather than reused as the same type? A. TypeScript requires two separate types for every value B. The wire format (e.g. snake_case fields, ISO date strings) often differs from how the app prefers to model the data internally (e.g. camelCase, Date objects), and conflating them hides that transformation C. Response DTOs must always be classes, while domain types must always be interfaces D. It has no real benefit — it's purely a style preference with no practical impact
B. The wire format (e.g. snake_case fields, ISO date strings) often differs from how the app prefers to model the data internally (e.g. camelCase, Date objects), and conflating them hides that transformation — A server's actual JSON shape frequently differs in naming convention, field types, and structure from how an application wants to work with that data internally, and giving the wire format its own DTO type makes the necessary transformation explicit and easy to keep correct as the API evolves.
Write a type guard isProduct(v: unknown): v is Product for type Product = { sku: string; price: number; inStock: boolean }, then write a function parseProduct(json: unknown): Product that returns the value narrowed to Product or throws a descriptive Error if it doesn't match.
type Product = { sku: string; price: number; inStock: boolean }; function isProduct(v: unknown): v is Product { if (typeof v !== "object" || v === null) return false; const o = v as Record<string, unknown>; return ( typeof o.sku === "string" && typeof o.price === "number" && typeof o.inStock === "boolean" ); } function parseProduct(json: unknown): Product { if (!isProduct(json)) { throw new Error("Invalid product data"); } return json; }
Check typeof for each field individually inside the guard; call the guard from parseProduct and throw if it returns false.