Strict Request Schemas in Axios TypeScript

Enforcing strict request schemas in an Axios HTTP client wrapper ensures both compile-time type safety and runtime payload integrity for outbound network calls. This article demonstrates how to build a robust TypeScript wrapper for Axios that pairs TypeScript generics with schema validation libraries like Zod to validate and type-check request parameters, bodies, and headers before requests are transmitted.

1. Define the Schema Architecture

To achieve strict runtime and compile-time validation, use a schema parser such as Zod. The parser infers static TypeScript types directly from runtime schemas, preventing discrepancies between your type definitions and actual validation logic.

import { z } from 'zod';

// Example: Strict schema for user creation
export const CreateUserSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
  age: z.number().int().positive().optional(),
}).strict(); // .strict() disallows unexpected properties

export type CreateUserPayload = z.infer<typeof CreateUserSchema>;

2. Design the Typed Request Contract

Create an interface for API route definitions. This structure maps endpoints to their expected request body, query parameters, and response types using schemas.

import { ZodType } from 'zod';

export interface RequestConfig<TBody = unknown, TParams = unknown> {
  url: string;
  bodySchema?: ZodType<TBody>;
  paramsSchema?: ZodType<TParams>;
}

3. Build the Axios Wrapper Class

Implement an ApiClient class that wraps Axios. The wrapper intercepts outgoing data, validates it against the provided schema using .parse() or .safeParse(), and only sends the request if the payload matches the strict schema definition.

import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { ZodType, ZodError } from 'zod';

export class ApiClient {
  private client: AxiosInstance;

  constructor(baseURL: string, defaultConfig?: AxiosRequestConfig) {
    this.client = axios.create({
      baseURL,
      headers: {
        'Content-Type': 'application/json',
      },
      ...defaultConfig,
    });
  }

  public async post<TResponse, TBody>(
    url: string,
    data: unknown,
    schema: ZodType<TBody>,
    config?: AxiosRequestConfig
  ): Promise<TResponse> {
    // Validate request body at runtime
    const validatedData = this.validate(schema, data);

    const response: AxiosResponse<TResponse> = await this.client.post(
      url,
      validatedData,
      config
    );

    return response.data;
  }

  public async get<TResponse, TParams>(
    url: string,
    params: unknown,
    schema?: ZodType<TParams>,
    config?: AxiosRequestConfig
  ): Promise<TResponse> {
    const validatedParams = schema ? this.validate(schema, params) : params;

    const response: AxiosResponse<TResponse> = await this.client.get(url, {
      ...config,
      params: validatedParams,
    });

    return response.data;
  }

  private validate<T>(schema: ZodType<T>, data: unknown): T {
    try {
      return schema.parse(data);
    } catch (error) {
      if (error instanceof ZodError) {
        throw new Error(
          `Outbound request validation failed: ${JSON.stringify(error.flatten().fieldErrors)}`
        );
      }
      throw error;
    }
  }
}

4. Execute Typed and Validated Requests

When using the client, TypeScript will require the payload to match the inferred type of the schema. If invalid or extra properties are passed at runtime, the validation layer intercepts the call before it reaches the network layer.

interface UserResponse {
  id: string;
  username: string;
  email: string;
  createdAt: string;
}

const api = new ApiClient('https://api.example.com');

async function createNewUser() {
  const invalidPayload = {
    username: 'jd', // Too short (min 3)
    email: 'not-an-email',
    role: 'superadmin', // Not in enum
    extraField: true // Disallowed by .strict()
  };

  const validPayload: CreateUserPayload = {
    username: 'johndoe',
    email: 'john@example.com',
    role: 'user',
  };

  // This will throw a runtime validation error before Axios sends the request
  // await api.post<UserResponse, CreateUserPayload>('/users', invalidPayload, CreateUserSchema);

  // This succeeds and returns a typed response
  const user = await api.post<UserResponse, CreateUserPayload>(
    '/users',
    validPayload,
    CreateUserSchema
  );

  return user;
}

5. Axios Interceptor Approach (Alternative)

For global validation across all Axios instances, use an Axios request interceptor. Attach metadata to custom Axios request configs to trigger validation automatically across your application without altering individual HTTP method signatures.

import axios, { InternalAxiosRequestConfig } from 'axios';
import { ZodType } from 'zod';

declare module 'axios' {
  export interface AxiosRequestConfig {
    bodySchema?: ZodType<unknown>;
  }
}

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

globalClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  if (config.bodySchema && config.data) {
    config.data = config.bodySchema.parse(config.data);
  }
  return config;
});