A production-ready TypeScript wrapper around fetch that validates API responses against Zod schemas at runtime, providing full type safety and structured error handling.
import { z, ZodSchema, ZodError } from 'zod';
// Custom error class for API-related errors
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly statusText: string,
public readonly url: string,
public readonly responseBody?: unknown
) {
super(message);
this.name = 'ApiError';
Object.setPrototypeOf(this, ApiError.prototype);
}
static isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}
}
// Custom error class for validation errors
export class ValidationError extends Error {
constructor(
message: string,
public readonly zodError: ZodError,
public readonly rawData: unknown
) {
super(message);
this.name = 'ValidationError';
Object.setPrototypeOf(this, ValidationError.prototype);
}
static isValidationError(error: unknown): error is ValidationError {
return error instanceof ValidationError;
}
}
// Configuration options for typedFetch
export interface TypedFetchOptions<T> extends Omit<RequestInit, 'body'> {
schema: ZodSchema<T>;
body?: unknown;
timeout?: number;
baseUrl?: string;
}
// Response wrapper with metadata
export interface TypedResponse<T> {
data: T;
status: number;
headers: Headers;
}
// Main typed fetch function
export async function typedFetch<T>(
url: string,
options: TypedFetchOptions<T>
): Promise<TypedResponse<T>> {
const {
schema,
body,
timeout = 30000,
baseUrl = '',
headers: customHeaders,
...fetchOptions
} = options;
const fullUrl = baseUrl ? new URL(url, baseUrl).toString() : url;
// Setup abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// Prepare headers
const headers = new Headers(customHeaders);
if (body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
try {
const response = await fetch(fullUrl, {
...fetchOptions,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Handle non-OK responses
if (!response.ok) {
let responseBody: unknown;
try {
responseBody = await response.json();
} catch {
responseBody = await response.text().catch(() => undefined);
}
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
fullUrl,
responseBody
);
}
// Parse JSON response
let rawData: unknown;
try {
rawData = await response.json();
} catch (parseError) {
throw new ApiError(
'Failed to parse JSON response',
response.status,
response.statusText,
fullUrl
);
}
// Validate against schema
const parseResult = schema.safeParse(rawData);
if (!parseResult.success) {
throw new ValidationError(
`Response validation failed: ${parseResult.error.message}`,
parseResult.error,
rawData
);
}
return {
data: parseResult.data,
status: response.status,
headers: response.headers,
};
} catch (error) {
clearTimeout(timeoutId);
// Re-throw our custom errors
if (ApiError.isApiError(error) || ValidationError.isValidationError(error)) {
throw error;
}
// Handle abort/timeout
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError(
`Request timeout after ${timeout}ms`,
0,
'Timeout',
fullUrl
);
}
// Handle network errors
if (error instanceof TypeError) {
throw new ApiError(
`Network error: ${error.message}`,
0,
'NetworkError',
fullUrl
);
}
throw error;
}
}
// Helper for creating a pre-configured client
export function createTypedClient(defaultOptions: Partial<TypedFetchOptions<unknown>> = {}) {
return async function <T>(
url: string,
options: TypedFetchOptions<T>
): Promise<TypedResponse<T>> {
return typedFetch(url, { ...defaultOptions, ...options });
};
}
// Usage example with real schemas
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime().transform((s) => new Date(s)),
});
const UsersArraySchema = z.array(UserSchema);
type User = z.infer<typeof UserSchema>;
// Example usage
async function fetchUser(id: number): Promise<User> {
const { data } = await typedFetch(`https://api.example.com/users/${id}`, {
schema: UserSchema,
method: 'GET',
timeout: 5000,
});
return data;
}
async function fetchUsers(): Promise<User[]> {
const { data } = await typedFetch('https://api.example.com/users', {
schema: UsersArraySchema,
method: 'GET',
});
return data;
}
async function createUser(userData: Omit<User, 'id' | 'createdAt'>): Promise<User> {
const { data } = await typedFetch('https://api.example.com/users', {
schema: UserSchema,
method: 'POST',
body: userData,
});
return data;
}
// Error handling example
async function safelyFetchUser(id: number): Promise<User | null> {
try {
return await fetchUser(id);
} catch (error) {
if (ApiError.isApiError(error)) {
if (error.status === 404) {
console.log('User not found');
return null;
}
console.error(`API Error: ${error.message}`, error.responseBody);
} else if (ValidationError.isValidationError(error)) {
console.error('Invalid response shape:', error.zodError.flatten());
}
throw error;
}
}This implementation provides a robust, type-safe wrapper around the native fetch API that combines TypeScript's compile-time type checking with Zod's runtime validation. The core insight is that TypeScript types are erased at runtime, so API responses need runtime validation to ensure type safety across the network boundary. By pairing a generic type parameter with a Zod schema, we get both IDE autocompletion and actual data validation.
The error handling strategy uses two distinct custom error classes: ApiError for HTTP-level failures (non-2xx responses, network issues, timeouts) and ValidationError for when the response doesn't match the expected schema. This separation allows consumers to handle these fundamentally different failure modes appropriately. The ApiError includes the response body when available, which is crucial for handling API error payloads that often contain validation messages or error codes. Both error classes include static type guard methods for safe narrowing in catch blocks.
The timeout implementation uses AbortController rather than relying on external libraries, making it compatible with all modern JavaScript environments. The timeout is properly cleaned up in both success and error paths to prevent memory leaks. Note that we also handle the edge case where JSON parsing fails separately from validation failures, as these require different debugging approaches.
The createTypedClient factory function demonstrates how to create pre-configured instances with default options like baseUrl or custom headers. This pattern is particularly useful when working with multiple APIs or when you need to inject authentication headers consistently. The options interface extends RequestInit but replaces body with unknown to handle automatic JSON serialization.
Use this pattern when building applications that communicate with JSON APIs and need runtime type guarantees. It's especially valuable in serverless functions, backend services, or any context where you're consuming third-party APIs. Avoid this pattern only when dealing with non-JSON responses (like file downloads) or when the performance overhead of validation is unacceptable in hot paths—though Zod is highly optimized and this is rarely a concern in practice.