← Back to Blog

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 Watson15 min readadvanced
E

Emily Watson

Technical Writer & Web Developer

Emily 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.

JSON BasicsJavaScriptWeb APIsDeveloper ToolingTechnical Writing
15 min read

# 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
  • Type Guards and Assertions
  • Discriminated Unions
  • Branded Types
  • Zod Schema Validation
  • io-ts for Runtime Types
  • Generating Types from JSON
  • Best Practices
  • ---

    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

    Remember:
    • 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.

    Share:

    Related Articles