TypeScript JSON Types: Typing API Responses, unknown, and Zod Validation

Last updated:

TypeScript gives you compile-time type safety, but JSON lives at runtime — and the two don't automatically agree. JSON.parse() returns any, fetch responses are untyped by default, and API contracts can silently drift from your TypeScript interfaces. This guide covers the complete toolkit: defining JSON types, typing API responses with interfaces and generics, safe JSON.parse patterns, Zod for runtime validation, and common pitfalls.

The JSON Type Hierarchy in TypeScript

TypeScript has no built-in JsonValue type, but defining one is straightforward. The recursive definition covers all valid JSON values.

// TypeScript does not have a built-in JSON type — define your own
type JsonPrimitive = string | number | boolean | null
type JsonArray = JsonValue[]
type JsonObject = { [key: string]: JsonValue }
type JsonValue = JsonPrimitive | JsonArray | JsonObject

// Or use the type from type-fest library (popular utility types)
// import type { JsonValue } from 'type-fest'

// JSON.parse always returns any — cast to unknown for safety
const raw: unknown = JSON.parse(text)

// Then narrow with type guards or Zod
if (typeof raw === 'object' && raw !== null && 'id' in raw) {
  const user = raw as User   // narrowed — still no runtime validation
}

Typing API Responses with Interfaces and Types

Define interfaces that match the exact shape of your API response. Use generics for reusable wrappers like paginated responses.

// Define the shape of your API response
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
  createdAt: string   // ISO 8601 — TypeScript sees it as string, not Date
  address?: {         // optional nested object
    city: string
    country: string
  }
}

// Paginated response wrapper (generic)
interface PaginatedResponse<T> {
  data: T[]
  meta: {
    total: number
    page: number
    perPage: number
    lastPage: number
  }
  links: {
    prev: string | null
    next: string | null
  }
}

// Usage
const response: PaginatedResponse<User> = await fetchJson('/api/users?page=1')
response.data.forEach(user => console.log(user.name))
response.meta.total   // number

Safe JSON.parse with unknown

Casting JSON.parse() directly with as T is a compile-time lie — no validation occurs at runtime. Use unknown and type guards instead.

// WRONG — casts directly to a type without validation
const user = JSON.parse(text) as User  // dangerous: user.id could be undefined

// CORRECT — parse to unknown, then validate
function safeJsonParse<T>(text: string, validator: (v: unknown) => T): T | null {
  try {
    const raw: unknown = JSON.parse(text)
    return validator(raw)
  } catch {
    return null
  }
}

// Type guard — manual validation
function isUser(v: unknown): v is User {
  return (
    typeof v === 'object' &&
    v !== null &&
    typeof (v as Record<string, unknown>).id === 'number' &&
    typeof (v as Record<string, unknown>).name === 'string' &&
    typeof (v as Record<string, unknown>).email === 'string'
  )
}

const raw: unknown = JSON.parse(text)
if (isUser(raw)) {
  console.log(raw.name)  // TypeScript knows raw is User here
}

Zod for Runtime Validation and TypeScript Types

Zod bridges the compile-time/runtime gap: define your schema once and get both runtime validation and TypeScript type inference from a single source of truth.

import { z } from 'zod'

// Define schema once — get both validation and TypeScript type
const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).max(200),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
  address: z.object({
    city: z.string(),
    country: z.string().length(2),  // ISO 3166-1 alpha-2
  }).optional(),
})

// Infer TypeScript type from schema (no duplication!)
type User = z.infer<typeof UserSchema>

// Validate JSON from API
const user = UserSchema.parse(JSON.parse(text))   // throws ZodError if invalid
const result = UserSchema.safeParse(JSON.parse(text))  // { success: true, data } or { success: false, error }

if (result.success) {
  console.log(result.data.name)   // typed as User
} else {
  console.error(result.error.issues)  // array of { path, message, code }
}

Generic Fetch Wrapper with TypeScript

A generic fetch helper adds TypeScript types to every API call. Pair it with Zod for full runtime safety in production.

// Type-safe fetch helper
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, {
    headers: { Accept: 'application/json', ...init?.headers },
    ...init,
  })
  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${res.statusText}`)
  }
  return res.json() as Promise<T>
}

// With Zod validation (recommended for production)
async function fetchValidated<T>(
  schema: z.ZodType<T>,
  url: string,
  init?: RequestInit
): Promise<T> {
  const res = await fetch(url, init)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  const raw = await res.json()
  return schema.parse(raw)    // throws ZodError if shape is wrong
}

// Usage
const users = await fetchJson<User[]>('/api/users')
const user = await fetchValidated(UserSchema, '/api/users/1')

as const and Literal Types for JSON Configuration

as const narrows all values in an object or array to their exact literal types, making TypeScript treat them as readonly value constants rather than general types.

// as const — narrows all values to literal types
const ROUTES = {
  home: '/',
  dashboard: '/dashboard',
  settings: '/settings',
} as const

type RouteKey = keyof typeof ROUTES         // 'home' | 'dashboard' | 'settings'
type RoutePath = typeof ROUTES[RouteKey]    // '/' | '/dashboard' | '/settings'

// Useful for typed config objects from JSON
const CONFIG = {
  theme: 'dark' as const,
  layout: 'sidebar' as const,
  maxItems: 100,
} satisfies Record<string, string | number>

// JSON enum pattern — union of literal strings
type Status = 'pending' | 'active' | 'cancelled' | 'refunded'
const STATUS_LABELS: Record<Status, string> = {
  pending: 'Pending review',
  active: 'Active',
  cancelled: 'Cancelled',
  refunded: 'Refunded',
}

Common TypeScript JSON Pitfalls

PitfallProblemSolution
JSON.parse(x) as TNo runtime check — type is wrong at runtimeUse unknown + type guard, or Zod
Date fields as DateJSON dates are stringsUse string type; convert with new Date()
number for IDsLarge IDs lose precision (> 2^53)Use string for snowflake/bigint IDs
any from JSON.parseDisables type checking entirelyReplace with unknown
Missing undefinedOptional JSON fields may be undefinedUse T | undefined or ?: syntax
Number vs "number"JSON numeric strings: "42"Parse explicitly: parseInt(value, 10)
// Correct Date handling
interface Event {
  id: number
  name: string
  scheduledAt: string    // keep as string in the interface
}

// Convert to Date only when needed (component, service layer)
const event = await fetchJson<Event>('/api/events/1')
const scheduledDate = new Date(event.scheduledAt)  // explicit conversion

Definitions

type guard
A TypeScript function with return type value is Type that narrows the type of a variable within an if block; enables safe type narrowing without runtime casting.
unknown
TypeScript type for values whose shape is unknown; stricter than any — you must narrow the type before using it; the correct type for JSON.parse() results.
z.infer
A Zod utility type that extracts the TypeScript type from a Zod schema; type User = z.infer<typeof UserSchema> gives you the TypeScript type without duplicating the definition.
literal type
A TypeScript type that is a specific value rather than a general type; 'admin' is a literal type, string is not; useful for typed JSON enums: type Role = 'admin' | 'editor' | 'viewer'.
satisfies
TypeScript 4.9+ operator that checks an expression against a type without widening it; const config = { theme: 'dark' } satisfies Config checks the shape while keeping literal types.

FAQ

What TypeScript type should I use for parsed JSON?

Use unknown as the logical type for JSON.parse() results — TypeScript's actual return type is any, but you should immediately assign to unknown to force type narrowing before use. Define a recursive JsonValue type: string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } for variables that can hold any valid JSON. For specific API shapes, define an interface or type alias that matches the expected response. Never use any unless you have genuinely no information about the JSON structure — any disables all type checking and defeats the purpose of TypeScript.

How do I type a JSON API response in TypeScript?

Define an interface or type alias that mirrors the API response shape: field names, their types, and whether they are optional. Use a generic fetch helper like fetchJson<User>('/api/users/1') to get a typed Promise. TypeScript compile-time types do not validate at runtime — if the API returns a different shape, your code may fail silently or throw at unexpected points. For production code, combine compile-time types with Zod schema validation to catch shape mismatches at the API boundary before they propagate into your application logic.

What is the difference between interface and type for JSON shapes?

Both interface and type alias work identically for describing the shape of a JSON object. The practical differences: interface allows declaration merging (two interface blocks with the same name merge into one), which is useful for extending library types. type aliases support union types, intersection types, and mapped types — things interface cannot express. Prefer interface for public API response types that consumers may need to extend. Prefer type for union types like type Status = 'active' | 'inactive' or for computed types like type Keys = keyof SomeInterface.

How does Zod help with TypeScript JSON validation?

TypeScript types exist only at compile time and are erased at runtime — they cannot validate actual JSON data your application receives. Zod fills this gap by validating JSON structure at runtime while also inferring TypeScript types at compile time. Define a z.object() schema once; use z.infer<typeof Schema> to extract the TypeScript type without duplicating field definitions. schema.parse(data) throws a ZodError with detailed path and message information if the data does not match. schema.safeParse(data) returns a discriminated union: { success: true, data } or { success: false, error }, which is easier to handle without try/catch.

How do I handle optional fields in TypeScript JSON types?

Use field?: Type for optional properties where the key may be absent entirely. Use field: Type | undefined for explicit undefined. Use field: Type | null for nullable fields where the JSON value can be null. These three cases are distinct: JSON null maps to TypeScript null, a missing JSON key maps to undefined in TypeScript, and an explicit undefined value cannot appear in JSON (JSON.stringify omits undefined values). In Zod: z.string().optional() for absent keys, z.string().nullable() for null values, z.string().nullish() for both absent and null.

Why should I avoid 'as' type assertions with JSON.parse?

The as T type assertion is a compile-time instruction to TypeScript to trust that a value has type T — it adds no runtime validation. If JSON.parse(text) as User is used and the API returns a different shape (missing fields, different types, extra nesting), TypeScript will not warn you and the error will manifest at runtime as undefined property access or silent wrong-type computation. Instead, assign the result to unknown first, then use a type guard function (function isUser(v: unknown): v is User) or Zod schema validation to confirm the shape before using it. This makes API contract violations visible at the point of ingestion rather than deep in your application code.

How do I type deeply nested JSON in TypeScript?

Define nested interfaces inline or as separate named interfaces. For inline nesting: address?: { city: string; country: string } inside a User interface. For shared sub-types, define a separate interface (Address) and reference it. For recursive structures like trees or nested comments, use an interface that references itself: interface TreeNode { value: string; children?: TreeNode[] }. TypeScript handles recursive interfaces correctly. For unknown-depth dynamic paths, use Record<string, unknown> at the nested level and narrow with type guards or Zod at the points where you access specific fields.

How do I handle JSON with dynamic keys in TypeScript?

Use index signatures for objects with arbitrary string keys: { [key: string]: Value } or the equivalent shorthand Record<string, Value>. For objects with some known keys and arbitrary additional keys, combine both: { knownField: string } & { [key: string]: unknown }. When iterating dynamic JSON objects, use Object.entries(obj) which gives [string, Value][] pairs. For runtime safety, Zod provides z.record(z.string(), z.unknown()) for validating a dynamic JSON object, or z.record(z.string(), z.number()) if you expect all values to be a specific type.

Further reading and primary sources

  • TypeScript Handbook: Types from TypesOfficial TypeScript handbook covering keyof, typeof, conditional types, mapped types, and template literal types
  • Zod DocumentationFull Zod API reference: schemas, transformations, error handling, and TypeScript integration
  • type-fest libraryCollection of essential TypeScript types including JsonValue, Jsonifiable, and hundreds of utility types