The TypeScript JSON Challenge
TypeScript gives you compile-time type safety, but JSON.parse() always returns any. This means the types you write are not enforced at runtime — a server can return a different shape than your TypeScript interfaces declare, and your code will silently break.
This guide covers how to handle JSON in TypeScript correctly: from basic type assertions to runtime schema validation with Zod. You can also generate TypeScript interfaces automatically from any JSON object, or generate a Zod schema in one click.
The Problem: JSON.parse Returns any
const data = JSON.parse(rawString); // type: any — unsafe!
const name = data.fullNam; // typo — TypeScript won't catch thisWith any, TypeScript provides no protection. Three progressively safer approaches:
Approach 1: Type Assertion (Quick but Risky)
interface User {
id: number;
name: string;
email: string;
}
const data = JSON.parse(rawString) as User;
console.log(data.name); // TypeScript is happy, but no runtime checkThis is fine for trusted, internal data. Never use it for external API responses or user input.
Approach 2: Unknown Type + Type Guard (Safer)
function isUser(val: unknown): val is User {
return (
typeof val === "object" &&
val !== null &&
"id" in val && typeof (val as User).id === "number" &&
"name" in val && typeof (val as User).name === "string" &&
"email" in val && typeof (val as User).email === "string"
);
}
const raw: unknown = JSON.parse(rawString);
if (isUser(raw)) {
console.log(raw.name); // fully typed, runtime-verified
} else {
throw new Error("Invalid user shape");
}This is verbose for large objects. Use Zod for complex shapes.
Approach 3: Zod Runtime Validation (Best for External Data)
Zod generates TypeScript types AND validates data at runtime:
npm install zodimport { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
tags: z.array(z.string()).optional(),
});
type User = z.infer<typeof UserSchema>; // TypeScript type derived from schema
function parseUser(raw: string): User {
const data = JSON.parse(raw);
return UserSchema.parse(data); // throws ZodError if invalid
}
// Or use safeParse for non-throwing version:
const result = UserSchema.safeParse(JSON.parse(raw));
if (result.success) {
console.log(result.data.name); // fully typed
} else {
console.error(result.error.issues); // detailed error list
}Generate Zod schemas automatically from any JSON using JSONKit's JSON to Zod generator at /json-to-zod.
Typed fetch Wrapper
A generic fetch helper that validates the response with Zod:
import { z, ZodType } from "zod";
async function fetchJson<T>(url: string, schema: ZodType<T>): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return schema.parse(data); // validates and returns typed data
}
// Usage:
const user = await fetchJson("/api/users/1", UserSchema);
console.log(user.email); // fully typed — safe to useGeneric JSON Response Wrapper
For APIs that return a standard envelope:
import { z } from "zod";
function apiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
success: z.boolean(),
data: dataSchema,
meta: z.object({
page: z.number().optional(),
total: z.number().optional(),
}).optional(),
});
}
const UserListResponse = apiResponseSchema(z.array(UserSchema));
type UserListResponse = z.infer<typeof UserListResponse>;Generating TypeScript Interfaces from JSON
Instead of writing interfaces by hand, use JSONKit's JSON to TypeScript generator at /json-to-typescript. Paste any JSON and get TypeScript interfaces instantly, including nested types and optional fields.
For example, pasting:
{
"id": 1,
"user": { "name": "Ravi", "role": "admin" },
"tags": ["developer"]
}Generates:
export interface Root {
id: number;
user: User;
tags: string[];
}
export interface User {
name: string;
role: string;
}JSON Serialization with TypeScript Classes
Use a toJSON() method to control serialization:
class User {
constructor(
public id: number,
public name: string,
private passwordHash: string, // should NOT appear in JSON
public createdAt: Date,
) {}
toJSON() {
return {
id: this.id,
name: this.name,
// passwordHash intentionally omitted
createdAt: this.createdAt.toISOString(),
};
}
}
const user = new User(1, "Ravi", "hashed...", new Date());
console.log(JSON.stringify(user));
// {"id":1,"name":"Ravi","createdAt":"2025-06-01T12:00:00.000Z"}Handling Dates in JSON
TypeScript (and JSON) have no built-in date type. The convention is ISO 8601 strings. Revive them when parsing:
interface Event {
name: string;
createdAt: Date;
}
function parseEvent(raw: string): Event {
const data = JSON.parse(raw);
return {
...data,
createdAt: new Date(data.createdAt), // revive ISO string to Date
};
}With Zod, use z.coerce.date() to handle this automatically:
const EventSchema = z.object({
name: z.string(),
createdAt: z.coerce.date(), // parses ISO strings to Date objects
});Avoiding JSON.parse(JSON.stringify(obj)) for Deep Clone
This common pattern strips type information and is slower than alternatives:
// Avoid:
const copy = JSON.parse(JSON.stringify(obj));
// Use instead (Node 17+, modern browsers):
const copy = structuredClone(obj);
// Or lodash:
import cloneDeep from "lodash/cloneDeep";
const copy = cloneDeep(obj);structuredClone correctly handles Dates, RegExps, Maps, Sets, and circular references — none of which survive JSON.stringify.