Validating API Response with Zod

Validating API Response with Zod

You've just integrated a new third-party API into your TypeScript application. Everything seems to work perfectly in development, but then production hits - and suddenly you're dealing with unexpected data formats, missing fields, and runtime errors that your TypeScript types didn't catch. Sound familiar?

API response validation is a critical yet often overlooked aspect of building robust applications. While TypeScript provides excellent compile-time type checking, it doesn't protect you from runtime surprises when dealing with external data sources. This is where Zod comes in - a TypeScript-first schema validation library that's been gaining massive traction in the developer community.

Why Validate API Responses?

Before diving into Zod, let's address a common question: "should it be done? If so why & how? If not, why?". The reality is that even well-documented APIs can return unexpected data:

  • API versions might change without notice

  • Documentation might be outdated or incorrect

  • Network issues could result in partial responses

  • Third-party services might have bugs or inconsistencies

Without proper validation, these issues can cascade through your application, causing hard-to-debug problems or, worse, data corruption.

Enter Zod: More Than Just Validation

As developers have noted, "it's not just the validation. It's the type generation. It's the DX, the way it's built, the modularity and composability." Zod offers a unique combination of features that make it particularly well-suited for API response validation:

  1. TypeScript-First Design: Zod generates TypeScript types automatically from your schemas

  2. Runtime Validation: Ensures data matches your expectations at runtime

  3. Excellent Developer Experience: Intuitive API with great error messages

  4. Composable Schemas: Build complex validations from simple building blocks

Let's start with a basic example of how to use Zod for API validation:

import { z } from 'zod';

// Define your schema
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1, 'Name cannot be empty'),
  createdAt: z.string().datetime(),
  status: z.enum(['active', 'inactive']),
});

// Type inference - TypeScript automatically knows the shape
type User = z.infer<typeof UserSchema>;

// Validate API response
async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  
  // This will throw if validation fails
  const validatedUser = UserSchema.parse(data);
  return validatedUser; // Fully typed and validated!
}

Safe Parsing and Error Handling

While parse() throws on invalid data, in many cases you'll want to handle validation errors gracefully. Zod provides safeParse() for exactly this purpose:

async function fetchUserSafely(id: string) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  
  const result = UserSchema.safeParse(data);
  if (!result.success) {
    console.error('Validation failed:', result.error);
    // Handle the error appropriately
    return null;
  }
  
  return result.data; // Typed as User
}

Building Complex Schemas

Real-world API responses often have nested structures and complex relationships. Zod makes it easy to compose schemas to match your needs:

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string(),
  postalCode: z.string()
});

const OrderItemSchema = z.object({
  productId: z.string(),
  quantity: z.number().int().positive(),
  price: z.number().positive()
});

const OrderSchema = z.object({
  id: z.string().uuid(),
  customer: UserSchema, // Reusing our previous schema
  items: z.array(OrderItemSchema),
  shippingAddress: AddressSchema,
  billingAddress: AddressSchema,
  total: z.number().positive(),
  status: z.enum(['pending', 'processing', 'shipped', 'delivered'])
});

Performance Considerations

A common concern with Zod is its performance impact. As discussed in the community, some developers worry about Zod being "very unoptimized." Here's what you need to know:

  1. For typical API responses (single objects or small arrays), the performance impact is negligible

  2. When validating large arrays or deeply nested objects, consider:

    • Validating only the critical parts of the response

    • Using .passthrough() for parts you don't need to strictly validate

    • Implementing pagination to handle smaller chunks of data

Here's an example of optimizing validation for large datasets:

const LargeResponseSchema = z.object({
  criticalData: z.object({
    // Strict validation for important fields
    id: z.string(),
    status: z.enum(['active', 'inactive'])
  }),
  // Less strict validation for non-critical data
  metadata: z.record(z.unknown()).passthrough()
});

Advanced Validation Patterns

Custom Validation Rules

Sometimes you need validation rules that go beyond simple type checking. Zod allows you to define custom validation rules using .refine():

const DateRangeSchema = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime()
}).refine(
  (data) => new Date(data.startDate) < new Date(data.endDate),
  {
    message: "End date must be after start date",
    path: ["endDate"] // Highlights which field caused the error
  }
);

Handling Special Types

One common challenge is handling special types like Firestore timestamps. As discussed in the community, you can create custom validators:

import { Timestamp } from 'firebase/firestore';

const TimestampSchema = z.custom<Timestamp>((val) => {
  return val instanceof Timestamp;
});

const DocumentSchema = z.object({
  title: z.string(),
  createdAt: TimestampSchema,
  updatedAt: TimestampSchema.nullable()
});

Partial Updates

When dealing with PATCH requests or partial updates, Zod's .partial() method is invaluable:

const UpdateUserSchema = UserSchema.partial();
// Now all fields are optional!

async function updateUser(id: string, updates: z.infer<typeof UpdateUserSchema>) {
  const result = UpdateUserSchema.safeParse(updates);
  if (!result.success) {
    throw new Error(`Invalid update data: ${result.error}`);
  }
  
  // Proceed with update...
}

Integration with API Clients

To make validation seamless, you can integrate Zod with your API client setup. Here's an example using axios:

import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com'
});

function createEndpoint<T extends z.ZodType>(path: string, schema: T) {
  return async (): Promise<z.infer<T>> => {
    const response = await api.get(path);
    const result = schema.safeParse(response.data);
    
    if (!result.success) {
      throw new Error(`API response validation failed: ${result.error}`);
    }
    
    return result.data;
  };
}

// Usage
const getUser = createEndpoint('/user', UserSchema);
const getOrders = createEndpoint('/orders', z.array(OrderSchema));

Best Practices and Common Pitfalls

When to Validate

While you might be tempted to validate every API response, it's important to be strategic. Here are some guidelines:

  1. Always validate:

    • Third-party API responses

    • Critical business logic endpoints

    • User-submitted data

  2. Consider skipping validation for:

    • Internal APIs where you control both ends

    • Non-critical static content

    • High-performance real-time updates

Error Handling Strategies

Proper error handling is crucial for a good user experience. Here's a comprehensive approach:

interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    message: string;
    details?: z.ZodError;
  };
}

async function fetchData<T extends z.ZodType>(
  url: string,
  schema: T
): Promise<ApiResponse<z.infer<T>>> {
  try {
    const response = await fetch(url);
    const data = await response.json();
    
    const result = schema.safeParse(data);
    if (!result.success) {
      return {
        success: false,
        error: {
          message: 'Invalid API response format',
          details: result.error
        }
      };
    }
    
    return {
      success: true,
      data: result.data
    };
  } catch (error) {
    return {
      success: false,
      error: {
        message: 'Failed to fetch data',
        details: error instanceof Error ? error.message : 'Unknown error'
      }
    };
  }
}

Conclusion

API response validation with Zod is more than just a safety net - it's a powerful tool that enhances your development experience and application reliability. While some developers question whether "introducing a dependency of this nature at such a fundamental level isn't without risks," the benefits often outweigh the concerns:

  • Catch data inconsistencies early

  • Improve type safety across your application

  • Provide better error messages

  • Reduce runtime errors in production

By following the patterns and practices outlined in this guide, you can effectively validate your API responses while maintaining good performance and code quality. Remember to balance validation thoroughness with practical considerations, and always test your validation logic thoroughly in production-like conditions.

For more information and advanced usage, check out the official Zod documentation and join the discussion in the TypeScript community.

11/13/2024
Related Posts
Validating TypeScript Types in Runtime using Zod

Validating TypeScript Types in Runtime using Zod

TypeScript enhances JavaScript by adding static types, but lacks runtime validation. Enter Zod: a schema declaration and validation library. Learn how to catch runtime data errors and ensure robustness in your TypeScript projects.

Read Full Story
Validating API Response with Yup

Validating API Response with Yup

Ensure your app's reliability with Yup by validating API responses. Learn to avoid silent crashes from unexpected data variations with robust schemas and error handling.

Read Full Story
How to Use Zod to Get Structured Data with LangChain

How to Use Zod to Get Structured Data with LangChain

Struggling with getting structured data from LLM? Discover how to leverage LangChain and Zod for bulletproof AI outputs.

Read Full Story