A contract-first, type-safe HTTP client with Zod validation for TypeScript.
Zodsei was created to solve the limitations of existing HTTP client libraries:
- 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
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
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.
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
- 🔒 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
- 🌐 Multiple HTTP clients: Support for fetch, axios, and ky through adapters
- 🚀 Minimal dependencies: Only requires Zod, HTTP clients are optional
- 📦 Modern: ESM/CJS dual package, works in Node.js and browsers
npm install zodsei zod
# or
pnpm add zodsei zod
# or
yarn add zodsei zod
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
}
});
import { createClient } from 'zodsei';
const client = createClient(apiContract, {
baseUrl: 'https://api.example.com',
validateRequest: true,
validateResponse: true,
// Type-safe adapter configuration - TypeScript infers the correct type based on adapter
adapter: 'fetch', // 👈 This determines adapterConfig type (FetchAdapterConfig)
adapterConfig: {
timeout: 10000,
credentials: 'include', // ✅ Valid for fetch
mode: 'cors', // ✅ Valid for fetch
cache: 'no-cache' // ✅ Valid for fetch
// auth: { username: 'user' } // ❌ TypeScript error: not valid for fetch
}
});
// 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 typed
// 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 schema
// Dev-time type helpers derived from the endpoint definition
type GetUserRequest = typeof client.getUser.infer.request;
type GetUserResponse = typeof client.getUser.infer.response;
// Runtime access to Zod schemas
const reqSchema = client.getUser.schema.request;
const resSchema = client.getUser.schema.response;
// Explore the contract at runtime
const endpointPaths = client.$schema.getEndpointPaths();
const info = client.$schema.describeEndpoint('getUser');
// info: { path, method, requestSchema, responseSchema, requestType, responseType }
type LoginRequest = typeof client.auth.login.infer.request;
const getByIdSchemas = client.users.getById.schema;
import { z } from 'zodsei'; // re-exported for convenience
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
const contract = defineContract({
endpointName: {
path: '/api/path/:param',
method: 'post',
request: z.object({ /* request schema */ }),
response: z.object({ /* response schema */ })
}
});
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' });
interface ClientConfig {
baseUrl: string; // Base URL for all requests
validateRequest?: boolean; // Enable request validation (default: true)
validateResponse?: boolean; // Enable response validation (default: true)
headers?: Record<string, string>; // Default headers
timeout?: number; // Request timeout in ms (default: 30000)
retries?: number; // Number of retries (default: 0)
middleware?: Middleware[]; // Custom middleware
}
Zodsei supports middleware for cross-cutting concerns:
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);
}
})
]
});
import { cacheMiddleware } from 'zodsei';
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
middleware: [
cacheMiddleware({
ttl: 60000, // Cache for 1 minute
})
]
});
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]
});
Zodsei supports multiple HTTP clients through a pluggable adapter system. Choose the adapter that best fits your needs:
// Fetch (default) - Zero dependencies
const client = createClient(contract, {
baseUrl: 'https://api.example.com'
// adapter: 'fetch' is implicit
});
// Axios - Full-featured HTTP client
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
adapter: 'axios'
});
// Ky - Modern with built-in retry
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
adapter: 'ky'
});
For advanced configuration and feature comparison, see the Advanced section below. For request/response lifecycle, use client-level middleware.
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');
}
}
// String-based with config
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
adapter: 'axios',
adapterConfig: {
timeout: 15000,
withCredentials: true
}
});
Feature | Fetch | Axios | Ky |
---|---|---|---|
Bundle Size | 0KB | ~13KB | ~4KB |
Dependencies | None | Required | Required |
Built-in | ✅ Native | ❌ Install required | ❌ Install required |
Platforms | Node.js, Browser | Node.js, Browser | Node.js, Browser |
Interceptors | ❌ | ❌ | ❌ |
Auto Retry | ❌ | ❌ | ✅ Built-in |
Advanced Features | Basic | Proxy, Auth, etc. | Hooks, Timeout |
Best For | Simple APIs | Complex APIs | Modern APIs |
Use middleware to implement cross-cutting concerns (auth, logging, retries, error handling):
const authMiddleware = async (req, next) => {
const token = localStorage.getItem('token');
if (token) req.headers.Authorization = `Bearer ${token}`;
return next(req);
};
const client = createClient(contract, {
baseUrl: 'https://api.example.com',
middleware: [authMiddleware]
});
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'
});
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
});
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]'
});
MIT
Contributions are welcome! Please read our contributing guide and submit pull requests to our repository.