Validate Axios with Zod and Yup Schemas

Validating HTTP requests and responses ensures that your application communicates safely with external APIs by enforcing strict data contracts at runtime. While TypeScript provides compile-time safety, runtime payloads can diverge from expected types. This guide explains how to enforce request and response schema validation using Zod and Yup alongside the Axios HTTP client through helper wrappers and Axios interceptors.

Why Validate Axios Payloads?

Axios automatically handles JSON serialization and deserialization, but it does not verify if the returned or dispatched data matches your application's expectations. Integrating schema validation libraries such as Zod or Yup solves two critical problems:

  1. Defensive Ingestion: Prevents malformed API response data from propagating through your UI or business logic.
  2. Defensive Mutation: Validates request payloads before sending them over the network, avoiding unnecessary failed requests.

Schema Validation with Zod

Zod uses TypeScript-first schema definitions with automatic type inference.

1. Validating Responses with a Wrapper Function

The simplest approach is wrapping Axios calls and parsing the data with Zod's .parse() or .safeParse() method.

import axios from 'axios';
import { z } from 'zod';

// Define the schema
export const UserResponseSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
});

// Infer TypeScript type directly from the schema
export type UserResponse = z.infer<typeof UserResponseSchema>;

// API call with validation
export async function getUser(userId: number): Promise<UserResponse> {
  const response = await axios.get(`https://api.example.com/users/${userId}`);
  
  // Throws a ZodError if validation fails
  return UserResponseSchema.parse(response.data);
}

2. Validating Requests with Zod

Validate user input or state before passing it to Axios:

export const CreateUserRequestSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;

export async function createUser(payload: CreateUserRequest) {
  // Validate the outgoing payload
  const validatedPayload = CreateUserRequestSchema.parse(payload);
  
  const response = await axios.post('https://api.example.com/users', validatedPayload);
  return response.data;
}

Schema Validation with Yup

Yup is another popular validation library frequently used in form validation and API layer integrity.

1. Validating Responses with Yup

Yup requires schemas to run through asynchronous validation functions like .validate():

import axios from 'axios';
import * as yup from 'yup';

export const ProductResponseSchema = yup.object({
  id: yup.number().required(),
  title: yup.string().required(),
  price: yup.number().positive().required(),
});

export type ProductResponse = yup.InferType<typeof ProductResponseSchema>;

export async function getProduct(productId: number): Promise<ProductResponse> {
  const response = await axios.get(`https://api.example.com/products/${productId}`);
  
  // Validate and strip unknown properties
  return await ProductResponseSchema.validate(response.data, {
    stripUnknown: true,
    abortEarly: false,
  });
}

2. Validating Requests with Yup

export const UpdateProductRequestSchema = yup.object({
  title: yup.string().min(3),
  price: yup.number().positive(),
});

export type UpdateProductRequest = yup.InferType<typeof UpdateProductRequestSchema>;

export async function updateProduct(productId: number, payload: UpdateProductRequest) {
  const validatedData = await UpdateProductRequestSchema.validate(payload, {
    abortEarly: false,
  });

  const response = await axios.put(
    `https://api.example.com/products/${productId}`,
    validatedData
  );
  return response.data;
}

Centralizing Validation Using Axios Interceptors

To avoid writing validation calls in every service function, you can attach schemas to custom Axios request configs using interceptors.

Defining Custom Config Types

Extend Axios to accept validation schemas:

import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
import { ZodSchema } from 'zod';

declare module 'axios' {
  export interface AxiosRequestConfig {
    requestSchema?: ZodSchema;
    responseSchema?: ZodSchema;
  }
}

Configuring the Interceptors

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

// Request Interceptor: Validates payload before sending
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  if (config.requestSchema && config.data) {
    config.data = config.requestSchema.parse(config.data);
  }
  return config;
});

// Response Interceptor: Validates received data
apiClient.interceptors.response.use((response: AxiosResponse) => {
  if (response.config.responseSchema) {
    response.data = response.config.responseSchema.parse(response.data);
  }
  return response;
});

Executing Validated Requests

import { z } from 'zod';

const TodoSchema = z.object({
  id: z.number(),
  title: z.string(),
  completed: z.boolean(),
});

const CreateTodoSchema = z.object({
  title: z.string().min(1),
});

export async function createTodoItem(title: string) {
  const response = await apiClient.post(
    '/todos',
    { title },
    {
      requestSchema: CreateTodoSchema,
      responseSchema: TodoSchema,
    }
  );

  return response.data; // Fully typed and validated
}

Error Handling

Validation failures throw specific errors depending on the library:

import { ZodError } from 'zod';
import { ValidationError } from 'yup';

try {
  await createTodoItem('');
} catch (error) {
  if (error instanceof ZodError) {
    console.error('Zod Validation Failed:', error.flatten());
  } else if (error instanceof ValidationError) {
    console.error('Yup Validation Failed:', error.errors);
  } else if (axios.isAxiosError(error)) {
    console.error('HTTP Request Failed:', error.response?.status);
  }
}

Using this pattern separates network errors from data contract mismatches, making it easier to pinpoint whether an issue originated from the network layer, client-side input, or backend schema changes.