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
- Cache compiled schemas — Zod schema compilation is expensive
- Use
strict()mode — Reject unknown properties early - Batch validation — Validate arrays once, not per-item
- 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).
Related Resources
- JSON Best Practices — Universal guidelines for all languages
- JSON Schema Validation — Validate your JSON structures
- JSON Security — Protect against common vulnerabilities
- JSON Formatter — Format and validate your JSON online
- JSON Schema Validator — Test your schemas