Complete Guide to TypeScript JSON Serialization

TypeScript ~3 min read
TypeScriptJSONSerializationType SafetyZod

Master TypeScript JSON serialization with type safety. Learn type-safe parsing, generic utilities, runtime validation with Zod, and advanced patterns for working with JSON in TypeScript.

The Type Safety Gap

TypeScript’s type system exists only at compile time — JSON.parse() returns any, losing all type information. This creates a dangerous gap between what you expect and what you actually receive.

interface User {
  name: string;
  age: number;
  email?: string;
}

// This compiles but provides NO runtime guarantees
const user: User = JSON.parse(jsonString);
// user could be ANYTHING at runtime!

Type Guards for Runtime Safety

Implement custom type guards to validate JSON structure:

interface User {
  name: string;
  age: number;
  email?: string;
}

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    typeof (obj as User).name === 'string' &&
    typeof (obj as User).age === 'number' &&
    ((obj as User).email === undefined || typeof (obj as User).email === 'string')
  );
}

// Safe parsing with validation
function parseUser(json: string): User {
  const parsed = JSON.parse(json);
  if (!isUser(parsed)) {
    throw new Error('Invalid user data');
  }
  return parsed;
}

Zod: Runtime Validation with Inference

Zod provides both runtime validation AND type inference:

import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(1),
  age: z.number().int().positive(),
  email: z.string().email().optional(),
  tags: z.array(z.string()).default([]),
});

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;

// Runtime validation
function parseUser(json: string): User {
  const data = JSON.parse(json);
  return UserSchema.parse(data); // Throws ZodError if invalid
}

// Safe parsing (returns result object)
function parseUser(json: string): User {
  const data = JSON.parse(json);
  const result = UserSchema.safeParse(data);
  if (result.success) {
    return result.data;
  } else {
    throw new Error(result.error.issues.map(i => i.message).join(', '));
  }
}

Generic JSON Parser

Create reusable, type-safe JSON parsing utilities:

import { z } from 'zod';

function createJsonParser<T>(schema: z.ZodType<T>) {
  return {
    parse: (json: string): T => schema.parse(JSON.parse(json)),
    safeParse: (json: string): { success: true; data: T } | { success: false; error: z.ZodError } => {
      try {
        const data = JSON.parse(json);
        return schema.safeParse(data);
      } catch (e) {
        return { success: false, error: new z.ZodError([{ message: 'Invalid JSON', path: [], code: 'custom' }]) };
      }
    }
  };
}

// Usage
const userParser = createJsonParser(UserSchema);
const user = userParser.parse(jsonString);

Discriminated Unions

Handle polymorphic JSON structures:

const EventSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('user_created'),
    payload: z.object({ userId: z.string(), name: z.string() }),
  }),
  z.object({
    type: z.literal('order_placed'),
    payload: z.object({ orderId: z.string(), amount: z.number() }),
  }),
]);

type Event = z.infer<typeof EventSchema>;

function handleEvent(event: Event) {
  switch (event.type) {
    case 'user_created':
      console.log(event.payload.userId); // Fully typed
      break;
    case 'order_placed':
      console.log(event.payload.amount); // Fully typed
      break;
  }
}

Advanced Patterns

Recursive Schemas

const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    children: z.array(CategorySchema).default([]),
  })
);

interface Category {
  name: string;
  children: Category[];
}

Transform and Preprocess

const UserSchema = z.object({
  name: z.string(),
  age: z.preprocess(
    (val) => (typeof val === 'string' ? parseInt(val, 10) : val),
    z.number().int().positive()
  ),
  createdAt: z.preprocess(
    (val) => (typeof val === 'string' ? new Date(val) : val),
    z.date()
  ),
});

Performance Considerations

  1. Cache compiled schemas — Zod schema compilation is expensive
  2. Use strict() mode — Reject unknown properties early
  3. Batch validation — Validate arrays once, not per-item
  4. Consider superjson — For complex types (Date, Map, Set)
import superjson from 'superjson';

const data = {
  date: new Date(),
  map: new Map([['key', 'value']]),
};

const { json, meta } = superjson.serialize(data);
// json: {"date":"2026-07-22T...","map":[["key","value"]]}
// meta: {"values":{"date":["Date"],"map":["Map"]}}

const restored = superjson.deserialize({ json, meta });
// Perfect round-trip with types preserved

Alternative Validation Libraries

While Zod is the most popular choice, several other libraries offer similar runtime validation with different trade-offs:

  • Valibot — A lightweight alternative to Zod with a similar API but significantly smaller bundle size (~70% smaller). Great for performance-sensitive applications.
  • TypeBox — Generates JSON Schema and TypeScript types simultaneously. Ideal when you need both runtime validation and schema documentation.
  • io-ts — A functional programming approach to runtime validation using the Either monad. Popular in FP-oriented TypeScript codebases.

Choose based on your priorities: bundle size (Valibot), schema interoperability (TypeBox), or functional style (io-ts).

JSON API Comparison Across Languages

Language Library Serialize Deserialize Pretty Print Config
Python json(内置) json.dumps(obj) json.loads(str) json.dumps(obj, indent=2) ensure_ascii, default, cls
JavaScript JSON(内置) JSON.stringify(obj) JSON.parse(str) JSON.stringify(obj, null, 2) replacer, space
Java Jackson / Gson mapper.writeValueAsString(obj) mapper.readValue(str, Class.class) mapper.writerWithDefaultPrettyPrinter() 注解、Module、Feature
Go encoding/json(内置) json.Marshal(obj) json.Unmarshal(data, &obj) json.MarshalIndent(obj, "", " ") struct tag
C# System.Text.Json(内置) JsonSerializer.Serialize(obj) JsonSerializer.Deserialize<T>(str) WriteIndented = true JsonSerializerOptions
TypeScript JSON(内置) JSON.stringify(obj) JSON.parse(str) JSON.stringify(obj, null, 2) replacer, space
Rust serde_json serde_json::to_string(&obj) serde_json::from_str::<T>(str) serde_json::to_string_pretty(&obj) serde attributes, custom (de)serializers

Frequently Asked Questions (FAQ)

How to handle datetime types in JSON serialization?

Most language JSON libraries do not support datetime types by default. The common approach is to serialize as ISO 8601 format strings (e.g. "2026-07-15T10:30:00Z") or Unix timestamps, then convert back to datetime objects during deserialization.

How to ignore null values or empty fields in JSON serialization?

Different languages have different implementations: Python can filter None values in custom encoders; JavaScript can use replacer functions; Java Jackson uses @JsonInclude annotations; Go uses omitempty struct tags; C# sets DefaultIgnoreCondition.

How to handle circular references in JSON serialization?

Circular references are a common pitfall. Standard JSON libraries usually throw exceptions or cause stack overflow. Solutions include using @JsonIdentityInfo (Java Jackson), implementing custom serializers, or converting to DTOs before serialization.

Why are my private fields not being serialized?

JSON serialization libraries typically only serialize public fields or properties with getters. This is a security consideration. You can use annotations like @JsonProperty (Java Jackson) or [JsonInclude] (C#) to include non-public members.

What are the performance optimization tips for JSON serialization?

1) Reuse serializer instances; 2) Use streaming APIs for large data; 3) Use compile-time generation (C# Source Generator, Go easyjson); 4) Avoid excessive nesting; 5) Use database pagination for large JSON.

Recommended Tools

These tools can help you handle JSON data more efficiently in TypeScript development:

Related Articles