A TypeScript-first composable HTTP client library that revolutionizes API interactions through procedure builders, schema validation, and intelligent retry logic. Works seamlessly across Node.js, browsers, and all modern JavaScript environments.
The modern web development landscape offers many excellent tools, but often forces you to make difficult trade-offs. This library was created to solve a fundamental problem: how do you get the benefits of composable, type-safe API interactions without being locked into specific frameworks or requiring particular backend setups?
The Problem:
- Framework Lock-in: Many solutions tie you to specific frameworks (React-only, server-only, etc.)
- Backend Requirements: Some tools require you to control the backend or use specific server implementations
- Type Safety Gaps: Manual typing between frontend and API calls leads to runtime errors
- Validation Scattered: Input/output validation often duplicated across frontend and backend
- Boilerplate Everywhere: Repetitive error handling, retry logic, and transformation code
The Solution: A framework-agnostic, composable HTTP client that brings the power of procedure builders and schema validation to any HTTP API, any JavaScript environment, and any framework - without requiring changes to your backend.
Whether you're building with React, Vue, Svelte, or vanilla JavaScript, working with REST APIs, JSON-RPC over HTTP, or custom HTTP-based backends, this library adapts to your stack instead of forcing your stack to adapt to it.
- 🎯 Composable: Build complex HTTP procedures using a fluent API
- 🔒 Type-safe: Full TypeScript support with Zod schema validation
- 🔄 Retry Logic: Built-in retry mechanisms with customizable delays
- 🧪 Dual HTTP Support: Works with both Axios and native Fetch
- 🎣 Lifecycle Hooks: onStart, onSuccess, onComplete hooks
- 🔧 Transform & Hooks: Transform responses and handle errors with lifecycle hooks
- ⚡ Rich Error Handling: Specialized error classes with type guards for precise error handling
- 🌐 Framework Agnostic: Works in Node.js (20+) and all modern browsers
- 🪶 Tiny Bundle: Only ~3.2KB gzipped - perfect for performance-conscious applications
- 📦 Multiple Formats: Supports both CJS and ESM for maximum compatibility
# npm
npm install composable-http-client zod
# pnpm (recommended)
pnpm add composable-http-client zod
# yarn
yarn add composable-http-client zod
Note: Zod is a dependency required for schema validation. While you can skip using
.input()
and.output()
methods, Zod will still be included in your bundle.
The library provides four tree-shakable entry points:
composable-http-client
- Core functionality (procedures, builders)composable-http-client/axios
- Axios HTTP client adaptercomposable-http-client/fetch
- Fetch HTTP client adaptercomposable-http-client/errors
- Error classes and type guards (optional)
import { createHttpClientProcedure } from 'composable-http-client';
import { createHttpClient } from 'composable-http-client/axios';
import { isHttpError } from 'composable-http-client/errors';
import { z } from 'zod';
// 1. Create HTTP client
const client = createHttpClient({
baseURL: 'https://api.example.com',
headers: { Authorization: 'Bearer your-token' },
});
// 2. Create procedure builder
const procedure = createHttpClientProcedure(client);
// Define schemas
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// 3. Build a type-safe procedure
const getUser = procedure()
.input(z.object({ userId: z.number() }))
.onStart(() => console.log('Fetching user...'))
.retry({ retries: 3, delay: 1000 })
.handler(async ({ input, client }) => {
return client.get(`/users/${input.userId}`);
})
.output(userSchema)
.onSuccess(() => console.log('User fetched successfully'))
.catchAll(error => {
if (isHttpError(error) && error.hasStatus(404)) {
return { error: 'User not found' };
}
return { error: error.message };
});
// 4. Use the procedure
const result = await getUser({ userId: 123 });
if (result.error) {
console.error('Error:', result.error);
} else {
console.log('User:', result.data); // Fully typed!
}
import { createHttpClientProcedure } from 'composable-http-client';
import { createHttpClient } from 'composable-http-client/fetch';
// Create HTTP client using native fetch (available in Node.js 20+ and all modern browsers)
const client = createHttpClient({
baseURL: 'https://api.example.com',
headers: { Authorization: 'Bearer your-token' },
});
const procedure = createHttpClientProcedure(client);
// ... rest is the same
The fetch client supports optional baseURL
parameter, which is particularly useful in Next.js and browser environments:
import { createHttpClient } from 'composable-http-client/fetch';
// With baseURL (traditional approach)
const clientWithBase = createHttpClient({
baseURL: 'https://api.example.com',
headers: { Authorization: 'Bearer your-token' },
});
// Without baseURL (new feature)
const clientWithoutBase = createHttpClient({
headers: { Authorization: 'Bearer your-token' },
});
// No parameters at all (most minimal)
const minimalClient = createHttpClient();
// Usage examples:
// With baseURL: relative paths are resolved against baseURL
await clientWithBase.get('/users'); // -> https://api.example.com/users
// Without baseURL: URLs are passed directly to fetch()
await clientWithoutBase.get('/api/users'); // -> /api/users (relative to current page in browser/Next.js)
await clientWithoutBase.get('https://api.example.com/users'); // -> absolute URL works too
// Minimal client: same behavior as without baseURL
await minimalClient.get('/api/users'); // -> /api/users (relative to current page)
await minimalClient.get('https://api.example.com/users'); // -> absolute URL works too
Environment Behavior:
- Browser/Next.js: Relative URLs like
/api/users
resolve against the current page URL - Node.js: Relative URLs require a base context, so use absolute URLs or provide a
baseURL
Note: Axios client requires baseURL
to maintain consistency with axios behavior.
This library is framework agnostic and works seamlessly across different JavaScript environments:
- Minimum version: Node.js 20.0.0+
- Axios adapter: Full support with interceptors
- Fetch adapter: Uses built-in
fetch
API (Node.js 20+) - CommonJS & ESM: Both module systems supported
- Modern browsers: Chrome, Firefox, Safari, Edge (ES2020+ compatible)
- Axios adapter: Full support in all browsers
- Fetch adapter: Uses native
fetch
API - Bundlers: Webpack, Vite, Rollup, Parcel compatible
- Axios adapter: Full support
- Fetch adapter: Uses React Native's built-in fetch
- Deno: Compatible with both adapters
- Bun: Full compatibility
- Web Workers: Both adapters work
- Service Workers: Fetch adapter recommended
// For maximum compatibility across all environments
import { createHttpClient } from 'composable-http-client/axios';
// For modern environments with native fetch (Node.js 20+, browsers)
import { createHttpClient } from 'composable-http-client/fetch';
// The core procedure builder works with any HTTP client
import { createHttpClientProcedure } from 'composable-http-client';
// Import error classes for type-safe error handling (tree-shakable)
import { HttpError, isHttpError } from 'composable-http-client/errors';
This library is specifically designed for HTTP-based APIs and excels with:
- REST APIs - Perfect fit with full HTTP verb support (GET, POST, PUT, DELETE, PATCH)
- JSON-RPC over HTTP - Excellent for procedure-based APIs
- Custom HTTP APIs - Any API that communicates over HTTP/HTTPS
- Microservices - Great for composing calls across multiple HTTP services
- Third-party APIs - Works with any external HTTP API (Stripe, GitHub, etc.)
- GraphQL over HTTP - Technically possible but not recommended
- Use dedicated GraphQL clients (Apollo, Relay) for better DX
- GraphQL already provides its own type system and validation
- WebSockets - Real-time communication (use Socket.io, native WebSockets)
- gRPC - Protocol buffer-based communication
- Database drivers - Direct database connections (use ORMs, query builders)
- File system operations - Local file access
- Message queues - Pub/sub systems (Redis, RabbitMQ, etc.)
Why HTTP-only? This library focuses on HTTP to provide the best possible developer experience for the most common API communication pattern, rather than trying to be a universal communication layer.
Creates a procedure builder that can be used to compose HTTP operations.
Validates input parameters using a Zod schema.
Defines the main logic for the procedure.
Validates output using a Zod schema or dynamic schema function.
Configures retry behavior:
.retry({
retries: 3,
delay: 1000 // or (currentAttempt, error) => currentAttempt * 1000
})
Transforms the output before validation:
.transform((output) => ({
...output,
timestamp: new Date().toISOString()
}))
.onStart(fn)
- Called before execution.onSuccess(fn)
- Called on successful completion.onComplete(fn)
- Called after execution (success or failure)
Handles errors and makes the procedure callable:
.catchAll((error) => ({
error: error.message,
code: error.code
}))
Both Axios and Fetch clients support comprehensive configuration:
interface ClientConfig<Tokens = Record<string, string>> {
baseURL: string;
timeout?: number;
headers?: Record<string, string> | ((tokens: Tokens) => Record<string, string>);
getTokens?: () => Tokens;
refreshToken?: () => Promise<void>;
logError?: (error: unknown) => Promise<void>;
addTracing?: (context: TracingContext) => Promise<void>;
}
Automatically refresh tokens on 401 responses:
const client = createHttpClient({
baseURL: 'https://api.example.com',
getTokens: () => ({
accessToken: localStorage.getItem('accessToken') || '',
refreshToken: localStorage.getItem('refreshToken') || '',
}),
refreshToken: async () => {
const refreshToken = localStorage.getItem('refreshToken');
const response = await fetch('/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
const { accessToken, refreshToken: newRefreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
},
headers: tokens => ({
Authorization: `Bearer ${tokens.accessToken}`,
}),
});
Handle different error types with structured responses:
const robustProcedure = procedure()
.input(userInputSchema)
.retry({
retries: 3,
delay: (currentAttempt, error) => {
// Exponential backoff for server errors
if (error.response?.status >= 500) {
return Math.pow(2, currentAttempt) * 1000;
}
// Fixed delay for rate limiting
if (error.response?.status === 429) {
return 5000;
}
// No retry for client errors
return 0;
},
})
.handler(async ({ input, client }) => {
return client.post('/users', input);
})
.catchAll(error => {
if (error.response?.status === 400) {
return {
type: 'VALIDATION_ERROR',
message: 'Invalid input data',
details: error.response.data.errors,
retryable: false,
};
}
if (error.response?.status >= 500) {
return {
type: 'SERVER_ERROR',
message: 'Server temporarily unavailable',
retryable: true,
retryAfter: 30000,
};
}
return {
type: 'UNKNOWN_ERROR',
message: error.message,
retryable: false,
};
});
Use dynamic schemas based on context:
.output(({ ctx, input, output }) => {
if (input.includeMetadata) {
return userWithMetadataSchema;
}
return userSchema;
})
Extend existing procedures with additional context:
import { extendProcedure } from 'composable-http-client';
const baseProcedure = createHttpClientProcedure(client);
const withAuth = extendProcedure(baseProcedure).handler(({ ctx }) => {
const user = getCurrentUser();
if (user.role !== 'admin') {
throw new Error('Insufficient permissions');
}
return { ...ctx, user };
});
const authenticatedProcedure = withAuth()
.input(z.object({ userId: z.string() }))
.handler(async ({ input, ctx, client }) => {
return client.get(`/admin/users/${input.userId}`);
})
.catchAll(error => ({ error: error.message }));
Handle file uploads with proper form data:
const uploadFile = procedure()
.input(
z.object({
file: z.instanceof(File),
metadata: z.object({
title: z.string(),
description: z.string().optional(),
}),
})
)
.handler(async ({ input, client }) => {
const formData = new FormData();
formData.append('file', input.file);
formData.append('metadata', JSON.stringify(input.metadata));
return client.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
})
.output(
z.object({
fileId: z.string(),
url: z.string().url(),
size: z.number(),
})
)
.catchAll(error => ({ error: error.message }));
import {
HttpError,
TimeoutError,
ValidationError,
RetryError,
TokenRefreshError,
NetworkError,
ConfigurationError,
isHttpError,
isTimeoutError,
isValidationError,
isRetryError,
isTokenRefreshError,
isNetworkError,
isConfigurationError,
type ComposableHttpErrorType,
} from 'composable-http-client/errors';
This library provides specialized error classes for different failure scenarios:
Thrown when HTTP requests fail with specific status codes.
const getUserProcedure = procedure()
.input(z.object({ userId: z.number() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.userId}`);
})
.catchAll(error => {
if (isHttpError(error)) {
console.log(`HTTP ${error.response.status}: ${error.message}`);
// Check error categories
if (error.isClientError) {
console.log('Client error (4xx)');
}
if (error.isServerError) {
console.log('Server error (5xx)');
}
// Check specific status codes
if (error.hasStatus(404)) {
return { type: 'NOT_FOUND', message: 'User not found' };
}
if (error.hasStatus(401)) {
return { type: 'UNAUTHORIZED', message: 'Authentication required' };
}
// Access response data
console.log('Response data:', error.response.data);
console.log('Response headers:', error.response.headers);
}
return { error: error.message };
});
Thrown when requests exceed the configured timeout.
.catchAll((error) => {
if (isTimeoutError(error)) {
console.log(`Request timed out after ${error.timeout}ms`);
return { type: 'TIMEOUT', message: 'Request took too long' };
}
return { error: error.message };
});
Thrown when input or output schema validation fails.
.catchAll((error) => {
if (isValidationError(error)) {
console.log(`${error.validationType} validation failed:`, error.zodError);
return {
type: 'VALIDATION_ERROR',
message: `Invalid ${error.validationType}`,
details: error.zodError
};
}
return { error: error.message };
});
Thrown when all retry attempts are exhausted.
.catchAll((error) => {
if (isRetryError(error)) {
console.log(`All ${error.attempts} retry attempts failed`);
console.log('Last error:', error.lastError.message);
return { type: 'RETRY_EXHAUSTED', message: 'Service temporarily unavailable' };
}
return { error: error.message };
});
Thrown when automatic token refresh fails.
.catchAll((error) => {
if (isTokenRefreshError(error)) {
console.log('Token refresh failed:', error.originalError?.message);
return { type: 'AUTH_ERROR', message: 'Please log in again' };
}
return { error: error.message };
});
Thrown for network-related failures (connection refused, DNS issues, etc.).
.catchAll((error) => {
if (isNetworkError(error)) {
console.log('Network error:', error.originalError?.message);
return { type: 'NETWORK_ERROR', message: 'Check your internet connection' };
}
return { error: error.message };
});
Thrown for configuration or setup issues.
.catchAll((error) => {
if (isConfigurationError(error)) {
console.log(`Configuration error in field: ${error.field}`);
return { type: 'CONFIG_ERROR', message: 'Invalid configuration' };
}
return { error: error.message };
});
Use type guards to handle different error types in a single .catchAll()
:
const robustProcedure = procedure()
.input(userInputSchema)
.retry({ retries: 3, delay: 1000 })
.handler(async ({ input, client }) => {
return client.get(`/users/${input.userId}`);
})
.catchAll((error: Error) => {
// HTTP errors
if (isHttpError(error)) {
if (error.hasStatus(404)) {
return { type: 'user_not_found', message: 'User not found' };
}
if (error.hasStatus(401)) {
return { type: 'unauthorized', message: 'Authentication required' };
}
if (error.isServerError) {
return { type: 'server_error', message: 'Server is unavailable' };
}
}
// Timeout errors
if (isTimeoutError(error)) {
return { type: 'timeout', message: 'Request took too long' };
}
// Validation errors
if (isValidationError(error)) {
return {
type: 'validation_error',
message: 'Invalid data',
validationType: error.validationType,
};
}
// Retry errors
if (isRetryError(error)) {
return { type: 'retry_exhausted', message: 'Service temporarily unavailable' };
}
// Token refresh errors
if (isTokenRefreshError(error)) {
return { type: 'auth_error', message: 'Please log in again' };
}
// Network errors
if (isNetworkError(error)) {
return { type: 'network_error', message: 'Check your internet connection' };
}
// Configuration errors
if (isConfigurationError(error)) {
return { type: 'config_error', message: 'Invalid configuration' };
}
// Generic error fallback
return { type: 'unknown_error', message: 'Something went wrong' };
});
Create reusable error handlers for consistent error management:
// Reusable error handler
function createErrorHandler<T>(defaultResponse: T) {
return (error: Error): T => {
if (isHttpError(error)) {
// Log HTTP errors for monitoring
console.error('HTTP Error:', {
status: error.response.status,
url: error.response.url,
data: error.response.data,
});
return defaultResponse;
}
if (isTimeoutError(error)) {
// Log timeout errors
console.warn(`Request timeout: ${error.timeout}ms`);
return defaultResponse;
}
// Log unexpected errors
console.error('Unexpected error:', error.message);
return defaultResponse;
};
}
// Use the reusable handler
const getUser = procedure()
.input(z.object({ userId: z.number() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.userId}`);
})
.catchAll(createErrorHandler({ error: 'Failed to fetch user' }));
import { describe, it, expect, vi } from 'vitest';
import { createHttpClientProcedure } from 'composable-http-client';
describe('User Procedures', () => {
// Mock HTTP client
const mockClient = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
const procedure = createHttpClientProcedure(mockClient);
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch user successfully', async () => {
// Arrange
mockClient.get.mockResolvedValue({
id: 1,
name: 'John Doe',
email: 'john@example.com',
});
const getUser = procedure()
.input(z.object({ userId: z.number() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.userId}`);
})
.output(userSchema)
.catchAll(error => ({ error: error.message }));
// Act
const result = await getUser({ userId: 1 });
// Assert
expect(result.error).toBeNull();
expect(result.data).toEqual({
id: 1,
name: 'John Doe',
email: 'john@example.com',
});
expect(mockClient.get).toHaveBeenCalledWith('/users/1');
});
});
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
const { id } = params;
return HttpResponse.json({
id: Number(id),
name: `User ${id}`,
email: `user${id}@example.com`,
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// ❌ Type inference lost without input schema
const procedure = createHttpClientProcedure(client).handler(({ input }) => {
// input type is 'unknown' because no .input() was called
return client.get('/users');
});
// ✅ Proper type inference with input schema
const userInputSchema = z.object({ userId: z.string() });
const procedure = createHttpClientProcedure(client)
.input(userInputSchema) // This enables type inference
.handler(({ input }) => {
// input is now properly typed as { userId: string }
return client.get(`/users/${input.userId}`);
});
// ✅ Detailed error handling
.catchAll(error => {
if (error instanceof z.ZodError) {
return {
error: 'Validation failed',
details: error.issues.map(issue => ({
path: issue.path.join('.'),
message: issue.message
}))
};
}
return { error: error.message };
});
Enable detailed logging for troubleshooting:
const client = createHttpClient({
baseURL: 'https://api.example.com',
logError: async error => {
console.error('HTTP Error:', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
config: error.config,
});
},
addTracing: async ({ method, url, config }) => {
console.log(`🔍 ${method.toUpperCase()} ${url}`, {
headers: config.headers,
data: config.data,
});
},
});
// Product catalog with pagination
const getProducts = procedure()
.input(
z.object({
category: z.string().optional(),
page: z.number().default(1),
limit: z.number().max(100).default(20),
sortBy: z.enum(['price', 'name', 'rating']).default('name'),
})
)
.handler(async ({ input, client }) => {
const params = new URLSearchParams();
Object.entries(input).forEach(([key, value]) => {
if (value !== undefined) params.set(key, String(value));
});
return client.get(`/products?${params}`);
})
.output(
z.object({
products: z.array(productSchema),
total: z.number(),
hasMore: z.boolean(),
})
)
.retry({ retries: 2, delay: 1000 })
.catchAll(error => ({ error: error.message }));
// Order creation with inventory validation
const createOrder = procedure()
.input(
z.object({
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().positive(),
})
),
shippingAddress: addressSchema,
paymentMethod: z.string(),
})
)
.onStart(() => analytics.track('order_creation_started'))
.handler(async ({ input, client }) => {
// Validate inventory first
const inventoryCheck = await client.post('/inventory/check', {
items: input.items,
});
if (!inventoryCheck.available) {
throw new Error('Some items are out of stock');
}
return client.post('/orders', input);
})
.output(orderSchema)
.onSuccess(() => {
analytics.track('order_created');
})
.catchAll(error => ({
error: error.message,
code: error.code,
recoverable: error.response?.status !== 400,
}));
// hooks/useApiProcedure.ts
import { useCallback, useMemo } from 'react';
import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';
export const useApiProcedure = () => {
const client = useMemo(() =>
createHttpClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || '/api',
headers: (tokens) => ({
'Content-Type': 'application/json',
...(tokens.accessToken && { Authorization: `Bearer ${tokens.accessToken}` })
}),
getTokens: () => ({
accessToken: localStorage.getItem('token') || ''
})
}), []
);
return useCallback(() => createHttpClientProcedure(client), [client]);
};
// components/UserProfile.tsx
import { useState, useEffect } from 'react';
import { useApiProcedure } from '../hooks/useApiProcedure';
import { z } from 'zod';
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email()
});
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const procedure = useApiProcedure();
useEffect(() => {
const getUser = procedure()
.input(z.object({ id: z.string() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.id}`);
})
.output(userSchema)
.onStart(() => setLoading(true))
.onSuccess(() => setLoading(false))
.catchAll(error => ({ error: error.message }));
getUser({ id: userId }).then(result => {
if (result.error) {
setError(result.error);
} else {
setUser(result.data);
}
setLoading(false);
});
}, [userId, procedure]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>Welcome, {user?.name}!</div>;
}
// composables/useApi.ts
import { createHttpClient } from 'composable-http-client/axios';
import { createHttpClientProcedure } from 'composable-http-client';
export const useApi = () => {
const config = useRuntimeConfig();
const client = createHttpClient({
baseURL: config.public.apiBase,
headers: (tokens) => ({
'Content-Type': 'application/json',
...(tokens.token && { Authorization: `Bearer ${tokens.token}` })
}),
getTokens: () => {
const token = useCookie('auth-token');
return { token: token.value || '' };
}
});
return createHttpClientProcedure(client);
};
// pages/users/[id].vue
<template>
<div>
<div v-if="pending">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>{{ data?.name }}</div>
</div>
</template>
<script setup>
const route = useRoute();
const api = useApi();
const getUser = api()
.input(z.object({ id: z.string() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.id}`);
})
.output(userSchema)
.catchAll(error => ({ error: error.message }));
const { data, pending, error } = await useAsyncData('user', () =>
getUser({ id: route.params.id as string })
);
</script>
// lib/api.ts
import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';
import { browser } from '$app/environment';
import { page } from '$app/stores';
const client = createHttpClient({
baseURL: browser ? '/api' : 'http://localhost:5173/api',
headers: tokens => ({
'Content-Type': 'application/json',
...(tokens.sessionId && { 'X-Session-ID': tokens.sessionId }),
}),
getTokens: () => ({
sessionId: browser ? document.cookie.split('sessionId=')[1]?.split(';')[0] : '',
}),
});
export const api = createHttpClientProcedure(client);
// routes/users/[id]/+page.ts
import { api } from '$lib/api';
import { z } from 'zod';
const userSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
export async function load({ params }) {
const getUser = api()
.input(z.object({ id: z.string() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.id}`);
})
.output(userSchema)
.catchAll(error => ({ error: error.message }));
const result = await getUser({ id: params.id });
if (result.error) {
throw error(500, result.error);
}
return {
user: result.data,
};
}
// Result type for all procedures
type Result<TData, TError> = {
data: TData | null;
error: TError | null;
};
// Procedure configuration interface
interface ProcedureConfig {
readonly inputSchema?: ZodType;
readonly outputSchemaOrFn?: OutputSchemaOrFn;
readonly mainHandler?: HandlerFunction;
readonly transformFn?: TransformFunction;
readonly retryOptions: RetryOptions;
readonly onStartFn?: () => void | Promise<void>;
readonly onSuccessFn?: () => void | Promise<void>;
readonly onCompleteFn?: (info: {
readonly isSuccess: boolean;
readonly isError: boolean;
readonly input: unknown;
readonly output: unknown;
readonly error: Error | undefined;
}) => void | Promise<void>;
readonly catchAllFn?: CatchAllFn;
readonly ctx: unknown;
readonly client: unknown;
}
// Retry configuration
interface RetryOptions {
retries: number;
delay: number | RetryDelay;
}
type RetryDelay = (currentAttempt: number, error: Error) => number;
type CompleteFn = (info: {
readonly isSuccess: boolean;
readonly isError: boolean;
readonly input: unknown;
readonly output: unknown;
readonly error: Error | undefined;
}) => void | Promise<void>;
Purpose: Validates and types input parameters using Zod schema.
Parameters:
schema: ZodType
- Zod schema for input validation
Returns: Procedure builder with typed input
Example:
.input(z.object({
userId: z.string().uuid(),
includeProfile: z.boolean().optional().default(false),
fields: z.array(z.string()).optional()
}))
Error Handling: Throws validation error if input doesn't match schema.
Purpose: Defines the main procedure logic.
Parameters:
fn: HandlerFunction
- Function that executes the HTTP request
Function Signature:
type HandlerFunction = (params: {
readonly input: TInput;
readonly ctx: TContext;
readonly client: TClient;
}) => Promise<TOutput> | TOutput;
Returns: Procedure builder with output type
Example:
.handler(async ({ input, ctx, client }) => {
// input is fully typed based on .input() schema
// ctx contains context from extended procedures
// client is the HTTP client instance
const response = await client.get(`/users/${input.userId}`, {
params: {
include_profile: input.includeProfile,
fields: input.fields?.join(',')
}
});
return response;
})
Purpose: Validates output using static or dynamic schema.
Parameters:
schemaOrFn: ZodType | OutputSchemaFunction
- Schema or function returning schema
Static Schema Example:
.output(z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
profile: z.object({
avatar: z.string().url(),
bio: z.string()
}).optional()
}))
Dynamic Schema Example:
.output(({ input, ctx, output }) => {
if (input.includeProfile) {
return userWithProfileSchema;
}
return basicUserSchema;
})
Purpose: Configures retry behavior for failed requests.
Options:
interface RetryOptions {
retries: number; // Number of retry attempts (default: 1)
delay: number | RetryDelay; // Delay between retries
}
type RetryDelay = (currentAttempt: number, error: Error) => number;
Examples:
// Fixed delay
.retry({ retries: 3, delay: 1000 })
// Exponential backoff
.retry({
retries: 5,
delay: (currentAttempt, error) => Math.pow(2, currentAttempt) * 1000
})
// Conditional retry based on error
.retry({
retries: 3,
delay: (currentAttempt, error) => {
if (error.response?.status === 429) return 5000; // Rate limit
if (error.response?.status >= 500) return currentAttempt * 2000; // Server error
return 0; // Don't retry client errors
}
})
Purpose: Transforms output before validation.
Function Signature:
type TransformFunction<TOutput, TTransformed> = (
output: TOutput
) => TTransformed | Promise<TTransformed>;
Examples:
// Add metadata
.transform((output) => ({
...output,
fetchedAt: new Date().toISOString(),
version: '1.0'
}))
// Transform data structure
.transform((output) => ({
...output,
displayName: output.name?.split(' ')[0] || 'Unknown'
}))
// Async transformation
.transform(async (output) => {
const enriched = await enrichUserData(output);
return enriched;
})
Called before procedure execution.
.onStart(async () => {
console.log('Starting user fetch...');
analytics.track('user_fetch_started');
showLoadingSpinner();
})
Called on successful completion.
.onSuccess(async () => {
console.log('User fetched successfully');
analytics.track('user_fetch_success');
})
Called after execution (success or failure).
.onComplete(async ({ isSuccess, isError, input, output, error }) => {
console.log(`Request completed`);
hideLoadingSpinner();
if (isError && error) {
analytics.track('user_fetch_error', { error: error.message });
} else if (isSuccess && output) {
analytics.track('user_fetch_complete', { userId: output.id });
}
})
// config/http-client.ts
import { createHttpClient } from 'composable-http-client/fetch';
const createProductionClient = () => {
return createHttpClient({
baseURL: process.env.API_BASE_URL,
timeout: parseInt(process.env.API_TIMEOUT || '30000'),
headers: tokens => ({
'Content-Type': 'application/json',
'User-Agent': `${process.env.APP_NAME}/${process.env.APP_VERSION}`,
...(tokens.accessToken && {
Authorization: `Bearer ${tokens.accessToken}`,
}),
}),
getTokens: () => ({
accessToken: getSecureToken(),
refreshToken: getRefreshToken(),
}),
refreshToken: async () => {
await refreshAuthTokens();
},
logError: async error => {
// Send to monitoring service
await logger.error('HTTP Client Error', {
message: error.message,
stack: error.stack,
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
data: error.response?.data,
});
},
addTracing: async ({ method, url, config }) => {
// Add distributed tracing
const span = tracer.startSpan(`http_${method.toLowerCase()}`);
span.setAttributes({
'http.method': method,
'http.url': url,
'http.user_agent': config.headers?.['User-Agent'],
});
},
});
};
// utils/error-monitoring.ts
import { captureException, addBreadcrumb } from '@sentry/node';
export const createMonitoredProcedure = (client: HttpClient) => {
return createHttpClientProcedure(client)
.onStart(() => {
addBreadcrumb({
message: 'HTTP request started',
category: 'http',
level: 'info',
});
})
.onComplete(({ isSuccess, isError, input, output, error }) => {
if (isError && error) {
captureException(error, {
tags: {
component: 'http-client',
},
});
}
});
};
// utils/cache.ts
import { LRUCache } from 'lru-cache';
const cache = new LRUCache<string, any>({
max: 500,
ttl: 1000 * 60 * 5, // 5 minutes
});
export const createCachedProcedure = (client: HttpClient) => {
return createHttpClientProcedure(client).handler(async ({ input, client }) => {
const cacheKey = JSON.stringify(input);
// Check cache first
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
// Make request
const result = await client.get('/data', { params: input });
// Cache result
cache.set(cacheKey, result);
return result;
});
};
This library is designed to work seamlessly with data fetching and caching libraries, giving your HTTP procedures superpowers:
import { useQuery, useMutation } from '@tanstack/react-query';
import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';
const client = createHttpClient({ baseURL: '/api' });
const procedure = createHttpClientProcedure(client);
// Create type-safe procedures
const getUser = procedure()
.input(z.object({ id: z.string() }))
.handler(async ({ input, client }) => client.get(`/users/${input.id}`))
.output(userSchema)
.catchAll(error => ({ error: error.message }));
const updateUser = procedure()
.input(z.object({ id: z.string(), data: updateUserSchema }))
.handler(async ({ input, client }) =>
client.put(`/users/${input.id}`, input.data)
)
.output(userSchema)
.catchAll(error => ({ error: error.message }));
// Use with React Query for caching, background updates, etc.
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => getUser({ id: userId }),
staleTime: 5 * 60 * 1000, // 5 minutes
});
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
// React Query handles caching, loading states, error states
// Composable HTTP Client handles validation, retry logic, type safety
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <UserForm user={user} onSave={mutation.mutate} />;
}
- 🎯 Focused Responsibilities: Data fetching libraries handle caching, background updates, and state management
- 🔒 Type Safety: Composable HTTP Client ensures end-to-end type safety and validation
- 🔄 Retry Logic: Built-in retry with exponential backoff
- 📝 Input Validation: Prevent invalid requests before they're sent
- 🛡️ Error Handling: Structured error responses that work with library error boundaries
- 🌐 Framework Agnostic: Use the same procedures across React, Vue, Svelte, etc.
Feature | Composable HTTP Client | Axios | Native Fetch |
---|---|---|---|
Type Safety | ✅ End-to-end with Zod | ❌ Manual typing | ❌ No typing |
Input Validation | ✅ Built-in with schemas | ❌ Manual validation | ❌ None |
Output Validation | ✅ Runtime validation | ❌ No validation | ❌ No validation |
Retry Logic | ✅ Built-in configurable | 🔧 Plugin required | ❌ Manual implementation |
Composability | ✅ Procedure builders | ❌ Not composable | ❌ Not composable |
Error Handling | ✅ Structured & typed | 🔧 Manual setup | 🔧 Manual try/catch |
Request/Response Transformation | ✅ Built-in | ✅ Interceptors | 🔧 Manual |
Lifecycle Hooks | ✅ onStart, onSuccess, onComplete | ❌ None | ❌ None |
Interceptors | ❌ None (has lifecycle hooks) | ✅ Full interceptor API | ❌ None |
Browser Support | ✅ Modern browsers | ✅ IE11+ | ✅ Modern browsers |
Node.js Support | ✅ 20+ | ✅ All versions | ✅ 18+ (native) |
Learning Curve | 📚 Medium | 📚 Low | 📚 Low |
Before (Axios):
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Add interceptors
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
api.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
await refreshToken();
return api.request(error.config);
}
return Promise.reject(error);
}
);
// Usage
const getUser = async (userId: string) => {
try {
const response = await api.get(`/users/${userId}`);
return response.data;
} catch (error) {
console.error('Failed to fetch user:', error);
throw error;
}
};
After (Composable HTTP Client):
import { createHttpClient } from 'composable-http-client/axios';
import { createHttpClientProcedure } from 'composable-http-client';
import { z } from 'zod';
const client = createHttpClient({
baseURL: 'https://api.example.com',
timeout: 10000,
headers: tokens => ({
'Content-Type': 'application/json',
...(tokens.accessToken && { Authorization: `Bearer ${tokens.accessToken}` }),
}),
getTokens: () => ({
accessToken: localStorage.getItem('token') || '',
}),
refreshToken: async () => {
await refreshToken();
},
});
const procedure = createHttpClientProcedure(client);
const getUser = procedure()
.input(z.object({ userId: z.string() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.userId}`);
})
.output(userSchema)
.retry({ retries: 3, delay: 1000 })
.catchAll(error => ({
error: error.message,
code: error.response?.status,
}));
Before (Manual Fetch):
const fetchUser = async (userId: string) => {
try {
const response = await fetch(`/api/users/${userId}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Failed to fetch user:', error);
throw error;
}
};
// Manual retry logic
const fetchWithRetry = async (userId: string, retries = 3) => {
for (let i = 0; i < retries; i++) {
try {
return await fetchUser(userId);
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
};
After (Composable HTTP Client):
import { createHttpClient } from 'composable-http-client/fetch';
import { createHttpClientProcedure } from 'composable-http-client';
import { z } from 'zod';
const client = createHttpClient({
baseURL: '/api',
headers: tokens => ({
'Content-Type': 'application/json',
...(tokens.accessToken && { Authorization: `Bearer ${tokens.accessToken}` }),
}),
getTokens: () => ({
accessToken: getToken(),
}),
});
const procedure = createHttpClientProcedure(client);
const getUser = procedure()
.input(z.object({ userId: z.string() }))
.handler(async ({ input, client }) => {
return client.get(`/users/${input.userId}`);
})
.output(userSchema)
.retry({
retries: 3,
delay: attempt => 1000 * Math.pow(2, attempt),
})
.catchAll(error => ({
error: error.message,
status: error.response?.status,
}));
// Usage with type safety and validation
const result = await getUser({ userId: '123' });
if (result.error) {
console.error('Error:', result.error);
} else {
console.log('User:', result.data); // Fully typed!
}
Q: Can I use this library without Zod validation?
A: Yes! While Zod is included as a dependency, you can skip the .input()
and .output()
methods and just use .handler()
and .catchAll()
for runtime functionality without validation.
const getUser = procedure()
.handler(async ({ client }) => {
return client.get('/users/123');
})
.catchAll(error => ({ error: error.message }));
Q: Which HTTP adapter should I choose?
A:
- Fetch adapter: Use for modern environments (Node.js 20+, modern browsers) when you want the smallest bundle size
- Axios adapter: Use for maximum compatibility, better error handling, and when you need advanced HTTP features
Q: Can I use multiple HTTP clients in the same application?
A: Absolutely! You can create different clients for different APIs:
const authClient = createHttpClient({ baseURL: 'https://auth.api.com' });
const dataClient = createHttpClient({ baseURL: 'https://data.api.com' });
const authProcedure = createHttpClientProcedure(authClient);
const dataProcedure = createHttpClientProcedure(dataClient);
Q: How do I handle dynamic response shapes?
A: Use dynamic output schemas:
.output(({ input }) => {
return input.detailed ? detailedSchema : basicSchema;
})
Q: Can I extend procedures with additional context?
A: Yes, use extendProcedure
:
const baseProcedure = createHttpClientProcedure(client);
const authProcedure = extendProcedure(baseProcedure).handler(() => ({ user: getCurrentUser() }));
Q: What's the difference between lifecycle hooks and interceptors?
A: Lifecycle hooks are procedure-level callbacks that run at specific points in the procedure execution:
- Lifecycle hooks: Procedure-specific, run for that specific procedure call
- Interceptors: Client-level, run for all requests through that HTTP client
// Lifecycle hooks (procedure-level)
const getUser = procedure()
.onStart(() => console.log('This procedure started'))
.handler(({ client }) => client.get('/users/1'))
.onSuccess(() => console.log('This procedure succeeded'));
// For interceptor-like behavior, use the underlying HTTP client's capabilities
const client = createHttpClient({
// This runs for ALL requests through this client
logError: async error => console.log('Global error:', error),
});
Q: How do I handle different types of errors?
A: Use structured error handling in .catchAll()
:
.catchAll((error) => {
if (error instanceof z.ZodError) {
return { type: 'VALIDATION_ERROR', details: error.issues };
}
if (error.response?.status === 401) {
return { type: 'AUTH_ERROR', message: 'Please log in' };
}
return { type: 'UNKNOWN_ERROR', message: error.message };
})
Q: How do I implement global error handling?
A: Use the logError
option in client configuration:
const client = createHttpClient({
baseURL: 'https://api.example.com',
logError: async error => {
// Send to monitoring service
errorReporter.capture(error);
},
});
Q: How do I implement caching?
A: You can implement caching in the handler:
const cache = new Map();
.handler(async ({ input, client }) => {
const cacheKey = JSON.stringify(input);
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
}
const result = await client.get('/data');
cache.set(cacheKey, result);
return result;
})
Q: What's the bundle size impact?
A:
- Core library: ~3.2KB gzipped
- With Zod: ~12.4KB gzipped total (Zod adds ~9KB)
- Axios adapter: +0.4KB gzipped
- Fetch adapter: +0.6KB gzipped
- Error classes: +0.8KB gzipped (optional import)
Q: How do I test procedures?
A: Mock the HTTP client:
const mockClient = {
get: vi.fn().mockResolvedValue({ id: 1, name: 'Test' }),
};
const procedure = createHttpClientProcedure(mockClient);
const getUser = procedure().handler(/*...*/).catchAll(/*...*/);
const result = await getUser({ userId: '1' });
expect(mockClient.get).toHaveBeenCalledWith('/users/1');
Q: How do I test with MSW?
A: Set up MSW handlers and use real HTTP client:
const server = setupServer(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Test User' });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
We welcome contributions! Please read our Contributing Guide for details on our development process.
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind welcome!
MIT License - see the LICENSE file for details.