zodsei
v2.0.0
Published
Contract-first type-safe HTTP client with Zod validation
Maintainers
Readme
Zodsei
A contract-first, type-safe HTTP client with Zod validation for TypeScript.
Why Zodsei?
Zodsei was created to solve the limitations of existing HTTP client libraries:
The Problem
- Zodios is unmaintained: The original Zodios library is no longer actively maintained, leaving users without updates and bug fixes
- Poor API design: Many existing solutions have complex, unintuitive APIs that are hard to use and maintain
- Limited flexibility: When you can't use tRPC or oRPC, or don't control the backend, you need a flexible contract-first solution
- Type safety gaps: Most HTTP clients lack comprehensive compile-time type checking and runtime validation
The Solution
Zodsei provides:
- Modern, clean API: Intuitive contract definition with
{path, method, request, response}structure - True contract-first: Define your API contract once, get full type safety everywhere
- Active maintenance: Built with modern tooling and actively maintained
- Flexible architecture: Works with any backend, no server-side requirements
- Complete type safety: From request to response, with runtime validation
When to Use Zodsei vs Other Solutions
For Full-Stack Projects (Recommended Alternatives)
If you're developing a full-stack project or have control over the backend, we recommend using these excellent alternatives:
- ts-rest - Contract-first REST APIs with full-stack type safety
- tRPC - End-to-end typesafe APIs made easy
- oRPC - Modern RPC framework with excellent TypeScript support
These libraries provide superior developer experience when you control both frontend and backend.
When Zodsei is the Right Choice
Use Zodsei when:
- 🔌 Consuming third-party APIs - You don't control the backend
- 🏢 Working with existing REST APIs - Legacy systems or external services
- 🔄 Migrating from unmaintained libraries - Moving away from Zodios or similar
- 🎯 Need flexible HTTP client - Custom requirements not covered by full-stack solutions
- 📱 Client-only applications - Mobile apps, browser extensions, or pure frontend projects
Features
- 🔒 Type-safe: Full TypeScript support with automatic type inference
- 📋 Contract-first: Define your API contract once, get type safety everywhere
- ✅ Runtime validation: Request and response validation using Zod schemas
- 🔌 Middleware support: Built-in retry, caching, and custom middleware
- 🌐 Axios-based client: Bring-your-own Axios instance for requests
- 🚀 Minimal dependencies: Zod + Axios
- 📦 Modern: ESM-only package for modern Node.js and browsers
Installation
bun add zodsei zod axiosQuick Start
1. Define your API contract
import { z } from 'zod';
import { defineContract } from 'zodsei';
const UserSchema = z.object({
id: z.uuid(),
name: z.string(),
email: z.email(),
});
const apiContract = defineContract({
getUser: {
path: '/users/:id',
method: 'get' as const,
request: z.object({
id: z.uuid(),
}),
response: UserSchema,
},
createUser: {
path: '/users',
method: 'post' as const,
request: z.object({
name: z.string().min(1),
email: z.email(),
}),
response: UserSchema,
},
});2. Create a client
import { createClient } from 'zodsei';
import axios from 'axios';
const axiosInstance = axios.create({ baseURL: 'https://api.example.com', timeout: 10000 });
const client = createClient(apiContract, {
axios: axiosInstance,
validateRequest: true,
validateResponse: true,
});3. Use the client
// Fully type-safe API calls
const user = await client.getUser({
id: '123e4567-e89b-12d3-a456-426614174000',
});
// user is automatically typed as { id: string, name: string, email: string }
const newUser = await client.createUser({
name: 'John Doe',
email: '[email protected]',
});
// newUser is also automatically typedCore Concepts
Type inference on endpoint methods
// Fully typed response inferred from the contract
const user = await client.getUser({ id: '123e4567-e89b-12d3-a456-426614174000' });
// `user` type is inferred from the endpoint response schemaContract type helpers
import type { InferRequestType, InferResponseType } from 'zodsei';
type GetUserRequest = InferRequestType<typeof apiContract.getUser>;
type GetUserResponse = InferResponseType<typeof apiContract.getUser>;Method-level schemas: .schema
// Runtime access to Zod schemas
const reqSchema = client.getUser.schema.request;
const resSchema = client.getUser.schema.response;Contract-level schema explorer: $schema
// Explore the contract at runtime
const endpointPaths = client.$schema.getEndpointPaths();
const info = client.$schema.describeEndpoint('getUser');
// info: { path, method, requestSchema, responseSchema }Nested contracts
type LoginRequest = InferRequestType<typeof contract.auth.login>;
const getByIdSchemas = client.users.getById.schema;Re-exported z
import { z } from 'zodsei'; // re-exported for convenienceAPI Reference
Contract Definition
Each endpoint in your contract should have:
path: The API endpoint path (supports path parameters like:id)method: HTTP method ('get' | 'post' | 'put' | 'delete' | 'patch')request: Zod schema for request dataresponse: Zod schema for response data
Basic Contract
const contract = defineContract({
endpointName: {
path: '/api/path/:param',
method: 'post',
request: z.object({/* request schema */}),
response: z.object({/* response schema */}),
},
});Nested Contracts
Contracts can be nested to organize your API endpoints by feature or module:
const contract = defineContract({
auth: defineContract({
login: {
path: '/auth/login',
method: 'post',
request: z.object({ email: z.string(), password: z.string() }),
response: z.object({ token: z.string() }),
},
logout: {
path: '/auth/logout',
method: 'post',
request: z.object({}),
response: z.object({ success: z.boolean() }),
},
}),
users: defineContract({
getById: {
path: '/users/:id',
method: 'get',
request: z.object({ id: z.string() }),
response: UserSchema,
},
}),
});
// Usage with nested structure
const loginResult = await client.auth.login({ email, password });
const user = await client.users.getById({ id: '123' });Client Configuration
interface ClientConfig {
axios: AxiosInstance; // Your Axios instance (required)
validateRequest?: boolean; // Enable request validation (default: true)
validateResponse?: boolean; // Enable response validation (default: true)
middleware?: Middleware[]; // Custom middleware
}Middleware
Zodsei supports middleware for cross-cutting concerns:
Retry Middleware
import { retryMiddleware } from 'zodsei';
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
middleware: [
retryMiddleware({
retries: 3,
delay: 1000,
backoff: 'exponential',
onRetry: (attempt, error) => {
console.log(`Retry attempt ${attempt}:`, error.message);
},
}),
],
});Cache Middleware
import { cacheMiddleware } from 'zodsei';
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
middleware: [
cacheMiddleware({
ttl: 60000, // Cache for 1 minute
}),
],
});Custom Middleware
const loggingMiddleware = async (request, next) => {
console.log('Request:', request);
const response = await next(request);
console.log('Response:', response);
return response;
};
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
middleware: [loggingMiddleware],
});HTTP Client
Zodsei uses Axios under the hood. You must provide an AxiosInstance when creating the client. Interceptors configured on that instance continue to run normally; Zodsei does not register or manage them.
Error Handling
Zodsei provides specific error types for different scenarios:
import { ValidationError, HttpError, NetworkError, TimeoutError } from 'zodsei';
try {
const user = await client.getUser({ id: 'invalid-uuid' });
} catch (error) {
if (error instanceof ValidationError) {
console.log('Validation failed:', error.issues);
} else if (error instanceof HttpError) {
console.log('HTTP error:', error.status, error.message);
} else if (error instanceof NetworkError) {
console.log('Network error:', error.message);
} else if (error instanceof TimeoutError) {
console.log('Request timeout');
}
}Advanced
Middleware vs Axios Interceptors
Middleware and Axios interceptors run at different layers:
| | Zodsei middleware | Axios interceptor |
| ----------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- |
| Input | RequestContext / ResponseContext | AxiosRequestConfig / AxiosResponse |
| Scope | One Zodsei client | Every consumer of the Axios instance |
| Best for | Caching, retries, client-specific auth, logging, mocks | Axios configuration, shared headers, 401 token refresh, Axios-specific errors |
| Can skip Axios entirely | Yes, for example on a cache hit | Not directly; it remains inside the Axios pipeline |
The execution order is:
request validation
→ Zodsei middleware
→ Axios request interceptor
→ HTTP request
← Axios response interceptor
← Zodsei middleware
← response validationUse middleware when behavior belongs to one contract client or may short-circuit the request. Use an interceptor when behavior depends on Axios internals or must be shared by every consumer of the Axios instance. Simple authorization headers can use either layer; 401 refresh flows usually belong in an Axios interceptor.
Do not configure retries or caching in both layers. Doing so can duplicate requests, logs, and side effects.
Path Parameters
const contract = defineContract({
getUserPosts: {
path: '/users/:userId/posts/:postId',
method: 'get' as const,
request: z.object({
userId: z.string().uuid(),
postId: z.string().uuid(),
}),
response: PostSchema,
},
});
// Usage
const post = await client.getUserPosts({
userId: 'user-uuid',
postId: 'post-uuid',
});Query Parameters
For GET requests, non-path parameters are automatically converted to query parameters:
const contract = defineContract({
searchUsers: {
path: '/users',
method: 'get' as const,
request: z.object({
q: z.string(),
page: z.number().optional(),
limit: z.number().optional(),
}),
response: z.object({
users: z.array(UserSchema),
total: z.number(),
}),
},
});
// Usage - generates: GET /users?q=john&page=1&limit=10
const results = await client.searchUsers({
q: 'john',
page: 1,
limit: 10,
});Request Body
For POST/PUT/PATCH requests, the request data is sent as JSON body:
const contract = defineContract({
updateUser: {
path: '/users/:id',
method: 'put' as const,
request: z.object({
id: z.string().uuid(), // Path parameter
name: z.string().optional(), // Body field
email: z.string().email().optional(), // Body field
}),
response: UserSchema,
},
});
// Usage
const updated = await client.updateUser({
id: 'user-uuid',
name: 'New Name',
email: '[email protected]',
});License
MIT
Contributing
Development uses Bun 1.3.14 or newer, with Oxlint and Oxfmt for linting and formatting:
bun install --frozen-lockfile
bun run check
bun run buildContributions are welcome! Please read our contributing guide and submit pull requests to our repository.
