jsontypescripttype-safetyzodgenericstutorial

JSON in TypeScript: Type Safety, Generics, and Runtime Validation

·9 min read

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

typescript
const data = JSON.parse(rawString); // type: any — unsafe!
const name = data.fullNam;          // typo — TypeScript won't catch this

With any, TypeScript provides no protection. Three progressively safer approaches:

Approach 1: Type Assertion (Quick but Risky)

typescript
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 check

This is fine for trusted, internal data. Never use it for external API responses or user input.

Approach 2: Unknown Type + Type Guard (Safer)

typescript
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:

bash
npm install zod
typescript
import { 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:

typescript
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 use

Generic JSON Response Wrapper

For APIs that return a standard envelope:

typescript
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:

json
{
  "id": 1,
  "user": { "name": "Ravi", "role": "admin" },
  "tags": ["developer"]
}

Generates:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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.

Try JSON to TypeScript

Generate TypeScript interfaces from any JSON response automatically.