Advanced TypeScript Patterns for JSON: Type Safety & Runtime Validation
Master advanced TypeScript techniques for JSON handling. Learn type guards, branded types, discriminated unions, Zod validation, and end-to-end type safety from API to UI.
Emily Watson
• Technical Writer & Web DeveloperEmily is a web developer and technical writer with 6 years of experience covering JavaScript ecosystems, developer tooling, and data formats. She specialises in making complex technical concepts approachable for developers at all levels, with a particular focus on JSON fundamentals, formatting best practices, and the tools developers reach for every day.
# Advanced TypeScript Patterns for JSON: Type Safety & Runtime Validation
TypeScript provides compile-time type safety, but JSON data from APIs, files, or user input is only validated at runtime. This guide covers advanced patterns to bridge this gap with type guards, branded types, discriminated unions, and validation libraries.
Table of Contents
---
The Type Safety Problem
TypeScript's any is the enemy of type safety, yet JSON parsing returns any:
const data = JSON.parse('{"name": "Alice", "age": 30}'); // data: any
console.log(data.email.toLowerCase()); // Runtime error! No compile error
Type assertions provide false confidence:
interface User {
name: string;
age: number;
}
const data = JSON.parse('{"name": "Alice"}') as User;
console.log(data.age.toFixed(2)); // Runtime error! age is undefined
Solution: Runtime validation that matches TypeScript types.
---
Type Guards and Assertions
Basic Type Guards
interface User {
name: string;
age: number;
email?: string;
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
typeof obj.name === 'string' &&
'age' in obj &&
typeof obj.age === 'number'
);
}
// Usage
const data: unknown = JSON.parse('{"name": "Alice", "age": 30}');
if (isUser(data)) {
console.log(data.name.toUpperCase()); // ✅ Type-safe!
console.log(data.age.toFixed(2)); // ✅ Type-safe!
} else {
throw new Error('Invalid user data');
}
Generic Type Guard Factory
type TypeGuard<T> = (value: unknown) => value is T;
function createArrayGuard<T>(itemGuard: TypeGuard<T>): TypeGuard<T[]> {
return (value: unknown): value is T[] => {
return Array.isArray(value) && value.every(itemGuard);
};
}
// Usage
const isUserArray = createArrayGuard(isUser);
const data: unknown = JSON.parse('[{"name": "Alice", "age": 30}]');
if (isUserArray(data)) {
data.forEach(user => {
console.log(user.name); // ✅ Type-safe!
});
}
Assertion Functions
function assertIsUser(obj: unknown): asserts obj is User {
if (!isUser(obj)) {
throw new Error('Not a valid user object');
}
}
// Usage
const data: unknown = JSON.parse('{"name": "Alice", "age": 30}');
assertIsUser(data);
console.log(data.name.toUpperCase()); // ✅ Type-safe after assertion
---
Discriminated Unions
Perfect for API responses with different shapes:
type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string }
| { status: 'loading' };
function handleResponse(response: ApiResponse<User>) {
switch (response.status) {
case 'success':
console.log(response.data.name); // ✅ TypeScript knows data exists
break;
case 'error':
console.error(response.error); // ✅ TypeScript knows error exists
break;
case 'loading':
console.log('Loading...'); // ✅ No data/error properties
break;
}
}
Type Guard for Discriminated Unions
function isSuccessResponse<T>(
response: ApiResponse<T>,
dataGuard: TypeGuard<T>
): response is { status: 'success'; data: T } {
return response.status === 'success' && dataGuard(response.data);
}
// Usage
const response: unknown = JSON.parse('{"status": "success", "data": {"name": "Alice", "age": 30}}');
if (typeof response === 'object' && response !== null && 'status' in response) {
const typedResponse = response as ApiResponse<unknown>;
if (isSuccessResponse(typedResponse, isUser)) {
console.log(typedResponse.data.name); // ✅ Fully type-safe!
}
}
Real-World Example: Event Handlers
type WebSocketEvent =
| { type: 'connected'; timestamp: number }
| { type: 'message'; userId: string; text: string; timestamp: number }
| { type: 'user_joined'; userId: string; username: string }
| { type: 'user_left'; userId: string }
| { type: 'error'; code: number; message: string };
function handleWebSocketEvent(event: WebSocketEvent) {
switch (event.type) {
case 'connected':
console.log('Connected at', new Date(event.timestamp));
break;
case 'message':
console.log(${event.userId}: ${event.text});
break;
case 'user_joined':
console.log(${event.username} joined);
break;
case 'user_left':
console.log(User ${event.userId} left);
break;
case 'error':
console.error(Error ${event.code}: ${event.message});
break;
}
}
---
Branded Types
Prevent mixing similar primitives:
type UserId = string & { readonly __brand: 'UserId' };
type Email = string & { readonly __brand: 'Email' };
function createUserId(id: string): UserId {
// Validation logic
if (!id.match(/^user_[a-z0-9]{20}$/)) {
throw new Error('Invalid user ID format');
}
return id as UserId;
}
function createEmail(email: string): Email {
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
return email as Email;
}
function sendEmail(to: Email, userId: UserId) {
console.log(Sending email to ${to} for user ${userId});
}
// Usage
const email = createEmail('alice@example.com');
const userId = createUserId('user_abc123def456ghi789jk');
sendEmail(email, userId); // ✅ Correct
// sendEmail(userId, email); // ❌ Compile error!
// sendEmail('plain-string', userId); // ❌ Compile error!
JSON Parsing with Branded Types
interface UserData {
id: UserId;
email: Email;
name: string;
}
function parseUserData(json: string): UserData {
const data = JSON.parse(json);
return {
id: createUserId(data.id),
email: createEmail(data.email),
name: data.name
};
}
// Usage with error handling
try {
const user = parseUserData('{"id": "user_abc123def456ghi789jk", "email": "alice@example.com", "name": "Alice"}');
sendEmail(user.email, user.id); // ✅ Type-safe!
} catch (error) {
console.error('Invalid user data:', error);
}
---
Zod Schema Validation
Zod provides type inference from schemas:
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(1),
age: z.number().int().positive(),
email: z.string().email().optional(),
roles: z.array(z.enum(['admin', 'user', 'guest'])).default(['user'])
});
type User = z.infer<typeof UserSchema>;
// Equivalent to:
// type User = {
// name: string;
// age: number;
// email?: string;
// roles: ('admin' | 'user' | 'guest')[];
// }
// Parse and validate
const result = UserSchema.safeParse(JSON.parse('{"name": "Alice", "age": 30}'));
if (result.success) {
const user: User = result.data;
console.log(user.name, user.roles); // ✅ Type-safe!
} else {
console.error(result.error.errors); // Detailed validation errors
}
Advanced Zod Patterns
Transform & Coerce:const DateSchema = z.string().transform((val) => new Date(val));
const EventSchema = z.object({
id: z.string().uuid(),
createdAt: DateSchema,
count: z.string().transform((val) => parseInt(val, 10))
});
const event = EventSchema.parse({
id: '550e8400-e29b-41d4-a716-446655440000',
createdAt: '2026-03-16T10:00:00Z',
count: '42'
});
console.log(event.createdAt instanceof Date); // true
console.log(typeof event.count); // 'number'
Refinements:
const PasswordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.refine(
(val) => /[A-Z]/.test(val),
'Password must contain uppercase letter'
)
.refine(
(val) => /[0-9]/.test(val),
'Password must contain number'
);
const result = PasswordSchema.safeParse('weakpass');
console.log(result.success); // false
console.log(result.error?.errors); // Detailed error messages
API Response Validation:
const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: dataSchema }),
z.object({ status: z.literal('error'), error: z.string() })
]);
const UserResponseSchema = ApiResponseSchema(UserSchema);
type UserResponse = z.infer<typeof UserResponseSchema>;
async function fetchUser(id: string): Promise<UserResponse> {
const response = await fetch(/api/users/${id});
const json = await response.json();
return UserResponseSchema.parse(json);
}
// Usage
const response = await fetchUser('123');
if (response.status === 'success') {
console.log(response.data.name); // ✅ Type-safe!
}
---
io-ts for Runtime Types
Alternative to Zod with functional programming style:
import * as t from 'io-ts';
import { isRight } from 'fp-ts/Either';
const User = t.type({
name: t.string,
age: t.number,
email: t.union([t.string, t.undefined])
});
type User = t.TypeOf<typeof User>;
const data: unknown = JSON.parse('{"name": "Alice", "age": 30}');
const result = User.decode(data);
if (isRight(result)) {
const user: User = result.right;
console.log(user.name); // ✅ Type-safe!
} else {
console.error('Validation failed');
}
---
Generating Types from JSON
quicktype
Generate TypeScript from JSON:
# Install
npm install -g quicktype
# Generate types
quicktype data.json -o types.ts --lang typescript
Input (data.json):
{
"users": [
{"name": "Alice", "age": 30, "email": "alice@example.com"}
]
}
Output (types.ts):
export interface Root {
users: User[];
}
export interface User {
name: string;
age: number;
email: string;
}
export function parseRoot(data: string): Root {
return JSON.parse(data);
}
JSON to Zod
Generate Zod schemas:
npx json-to-zod < data.json > schema.ts
---
Best Practices
1. Always Validate External Data
// ❌ Bad
const user = JSON.parse(input) as User;
// ✅ Good
const user = UserSchema.parse(JSON.parse(input));
2. Use Discriminated Unions for Complex Types
// ❌ Bad
interface ApiResponse {
data?: User;
error?: string;
}
// ✅ Good
type ApiResponse =
| { status: 'success'; data: User }
| { status: 'error'; error: string };
3. Validate at Boundaries
// API layer validates incoming data
app.post('/users', (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
// Now data is type-safe throughout the application
const user = result.data;
saveUser(user);
});
4. Provide Good Error Messages
const result = UserSchema.safeParse(data);
if (!result.success) {
const errors = result.error.flatten();
console.error('Validation failed:', JSON.stringify(errors, null, 2));
}
5. Use Partial & Pick Wisely
// Update payload: all fields optional
const UserUpdateSchema = UserSchema.partial();
// Login payload: only email and password
const LoginSchema = UserSchema.pick({ email: true, password: true });
---
Conclusion
Bridge the gap between TypeScript's compile-time types and runtime JSON data:
Essential patterns:- Type guards for validation
- Discriminated unions for complex types
- Branded types for primitive safety
- Zod/io-ts for runtime validation
- Always validate external data
- Type assertions (
as) are unsafe without validation - Generate types from JSON when possible
- Validate at system boundaries
Invest in robust validation—it prevents bugs and provides better DX with autocomplete and type safety throughout your codebase.
Related Resources
Related Articles
JSON Security Vulnerabilities: Complete Protection Guide 2026
Protect your APIs from JSON security vulnerabilities. Learn about injection attacks, prototype pollution, DoS attacks, and implement security best practices for safe JSON processing in production applications.
JSON APIs and REST Services: Complete Development Guide
Learn to build and consume JSON-based REST APIs. Covers HTTP methods, authentication, best practices, and real-world implementation examples.