Free online JSON formatter, validator, and developer tools.
TypeScript types exist only at compile time. The moment you call await response.json(), TypeScript happily lets you assert the result is whatever shape you want โ but nothing checks that the actual bytes coming over the wire match. A typed fetch response is still, at runtime, just any wearing a costume. This guide walks through how to type JSON correctly with interfaces and generics, and โ more importantly โ how to close the gap between "the compiler believes this" and "the data actually is this" using runtime validation.
Consider a typical API call:
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json(); // TypeScript trusts you here โ no verification happens
}
The res.json() call returns Promise<any>. By declaring the function's return type as Promise<User>, you are making a promise to the compiler, not proving a fact. If the backend changes email to emailAddress, or omits it entirely, or returns null for a field you typed as required, TypeScript will not warn you. The error surfaces later, often as Cannot read properties of undefined, far from where the bad data entered your system.
This is the core tension when working with JSON in TypeScript: JSON is untyped, dynamic, and produced outside your program, while TypeScript's type system is static and erased before your code ever runs. Interfaces alone document intent โ they do not enforce it.
Despite the type-erasure caveat, interfaces are still essential โ they give you autocomplete, refactoring safety, and documentation. Model the JSON shape as closely as possible, including which fields are actually optional:
interface Address {
street: string;
city: string;
zip: string;
}
interface User {
id: number;
name: string;
email: string;
address?: Address; // optional โ API may omit this
role: "admin" | "member"; // union type, not a bare string
tags: string[];
createdAt: string; // ISO date string, NOT a Date object
}
Three details matter here. First, mark fields optional (?) only when the API genuinely may omit them โ marking everything optional defeats the purpose of typing. Second, use string literal unions ("admin" | "member") instead of string whenever the API has a fixed, known set of values; this catches typos and lets the compiler flag missing switch cases. Third, remember that JSON.parse never produces a Date โ timestamps arrive as ISO strings, and converting them is your responsibility, not TypeScript's.
Real API payloads are rarely flat. Nested objects, arrays of objects, and polymorphic shapes (a field whose structure depends on another field) all need deliberate modeling.
interface Order {
id: string;
items: OrderItem[];
shipping: Address;
total: number;
}
interface OrderItem {
sku: string;
quantity: number;
unitPrice: number;
}
// Discriminated union for a "kind" field returned by the API
type PaymentMethod =
| { kind: "card"; last4: string; brand: string }
| { kind: "paypal"; email: string }
| { kind: "bank_transfer"; iban: string };
function describePayment(method: PaymentMethod): string {
switch (method.kind) {
case "card":
return `Card ending in ${method.last4}`;
case "paypal":
return `PayPal (${method.email})`;
case "bank_transfer":
return `Bank transfer to ${method.iban}`;
}
}
Discriminated unions are the correct TypeScript idiom for JSON payloads where a type or kind field determines the rest of the object's shape โ a pattern extremely common in webhook payloads, event logs, and polymorphic API responses. The compiler narrows the type automatically inside each case branch, and if you add a new payment method variant later, an exhaustive switch will fail to compile until you handle it.
Writing a separate fetch function for every endpoint gets repetitive fast. Generics let you write one typed fetch wrapper and reuse it across your entire API surface:
async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
if (!res.ok) {
throw new Error(`Request to ${path} failed: ${res.status}`);
}
return res.json() as T;
}
// Usage โ T is inferred from the call site
const user = await apiGet<User>("/api/users/42");
const orders = await apiGet<Order[]>("/api/orders");
This is a genuine ergonomic win: one function, fully typed call sites, no duplication. But notice the as T cast โ it is exactly the same trust-the-compiler problem from the introduction, just centralized in one place instead of scattered across every fetch call. Centralizing the cast is still valuable because it gives you exactly one place to add real validation, which is the next step.
TypeScript interfaces are erased during compilation โ open your compiled .js output and you will not find a single trace of interface User. That means there is no way to ask, at runtime, "does this object satisfy the User interface?" using TypeScript alone. For data that originates outside your program โ API responses, localStorage, query parameters, webhook bodies, config files โ you need a library that defines the shape once and checks it both at compile time (for types) and at runtime (for actual validation).
Zod is the most widely adopted option in the current TypeScript ecosystem. You define a schema, and Zod derives the TypeScript type from it automatically โ so the type and the validator can never drift apart:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
tags: z.array(z.string()),
createdAt: z.string().datetime(),
});
// Type is inferred FROM the schema โ single source of truth
type User = z.infer<typeof UserSchema>;
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const data = await res.json();
return UserSchema.parse(data); // throws ZodError if shape doesn't match
}
The critical shift here: UserSchema.parse(data) actually inspects the object at runtime and throws a descriptive error the moment the API returns something unexpected โ a missing field, a string where a number was expected, an invalid email. You catch bad data at the network boundary instead of three function calls later. If you would rather not throw, UserSchema.safeParse(data) returns a discriminated { success: true, data } / { success: false, error } result you can handle explicitly.
io-ts solves the same problem with an fp-ts-flavored API, favoring composability and explicit Either-based error handling over exceptions:
import * as t from "io-ts";
import { isRight } from "fp-ts/Either";
const User = t.type({
id: t.number,
name: t.string,
email: t.string,
role: t.union([t.literal("admin"), t.literal("member")]),
});
type User = t.TypeOf<typeof User>;
const result = User.decode(data);
if (isRight(result)) {
const user: User = result.right; // validated
} else {
console.error("Invalid user payload", result.left);
}
Choose Zod if you want a smaller learning curve and a fluent, chainable API โ it has become the de facto default for new projects, especially alongside frameworks like tRPC and Next.js Server Actions that use Zod schemas directly as their type-safety boundary. Choose io-ts if your codebase already embraces fp-ts and functional error handling throughout, since io-ts composes more naturally with that style.
Combining the generic fetcher from earlier with Zod validation gives you a single, reusable, fully-safe API client function:
import { z, ZodSchema } from "zod";
async function apiGet<T>(path: string, schema: ZodSchema<T>): Promise<T> {
const res = await fetch(path);
if (!res.ok) {
throw new Error(`Request to ${path} failed: ${res.status}`);
}
const data = await res.json();
return schema.parse(data); // validated AND typed, no "as" cast anywhere
}
// Usage
const user = await apiGet("/api/users/42", UserSchema);
// `user` is fully typed as User, and guaranteed to match the schema at runtime
Notice there is no manual type parameter and no as cast anywhere in this version โ TypeScript infers T from the schema you pass in, and Zod guarantees the value actually matches it. This is the pattern worth adopting: stop writing bare interfaces for data you do not control, and instead write one schema that produces both the compile-time type and the runtime check.
For very small payloads, or when you want zero dependencies, a hand-written type guard is a lightweight alternative to a full validation library:
interface Ping {
status: "ok" | "error";
timestamp: number;
}
function isPing(value: unknown): value is Ping {
return (
typeof value === "object" &&
value !== null &&
"status" in value &&
(value as any).status === ("ok" || "error") &&
"timestamp" in value &&
typeof (value as any).timestamp === "number"
);
}
const data: unknown = await res.json();
if (isPing(data)) {
console.log(data.timestamp); // safely narrowed to Ping
}
Type guards scale poorly for complex, nested, or frequently-changing shapes โ you end up re-implementing a validation library by hand, one field check at a time. Reach for Zod or io-ts once a payload has more than two or three fields, contains nested objects, or is validated in more than one place in your codebase.
TypeScript interfaces describe what you expect JSON to look like; they do not verify it. Because types are erased at compile time, any JSON entering your program from a fetch call, localStorage, or a webhook must be validated at runtime if you want real safety, not just an illusion of it. Use interfaces and discriminated unions to model the shape clearly, use generics to keep your fetch layer DRY, and use a schema library like Zod (or io-ts, for an fp-ts-based codebase) so the type and the validator are derived from a single source of truth. That combination โ static types for developer experience, runtime validation for correctness โ is what actually keeps your types and your data in sync.
Working with a raw JSON payload before you write the interface? Paste it into our free JSON Formatter to inspect the structure, or use the JSON Schema Generator to scaffold a schema straight from a sample response.