Schema Validation
Achieve end-to-end type safety with Standard Schema. Define API contracts with Zod, get automatic TypeScript inference, and validate requests/responses at runtime.
Schema validation is what makes Caller truly powerful. By defining your API contract using Zod (or any Standard Schema V1 compatible library), you get automatic runtime validation and perfect TypeScript inference — without writing a single interface by hand.
Why Schema Validation?
Without schema validation, this is what happens:
// ❌ Traditional approach — no type safety
const response = await fetch('/api/users/1');
const user = await response.json();
// user is any — no autocomplete, no validation
console.log(user.naem); // Typo! Runtime error.With Caller schemas:
// ✅ Caller approach — full type safety
const result = await api.get('/users/:id')
.params({ id: '1' })
.execute();
// result.data is fully typed — autocomplete works
// Runtime validation catches mismatchesQuick Example
Here's a complete schema validation setup in under 50 lines:
import { IgniterCaller, IgniterCallerSchema } from '@igniter-js/caller';
import { z } from 'zod';
// 1. Define your schemas
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// 2. Build the schema registry
const schemas = IgniterCallerSchema.create()
.path('/users', (path) =>
path.get({
responses: { 200: z.array(UserSchema) },
})
.post({
request: z.object({
name: z.string(),
email: z.string().email(),
}),
responses: {
201: UserSchema,
400: z.object({ message: z.string() }),
},
})
)
.path('/users/:id', (path) =>
path.get({
responses: {
200: UserSchema,
404: z.object({ message: z.string() }),
},
})
.delete({
responses: {
204: undefined,
404: z.object({ message: z.string() }),
},
})
)
.build();
// 3. Create the typed client
const api = IgniterCaller.create()
.withBaseUrl('https://api.example.com')
.withSchemas(schemas, { mode: 'strict' })
.build();
// 4. Enjoy full type inference!
const { data } = await api.get('/users/:id')
.params({ id: '1' })
.execute();
// data: { id: number; name: string; email: string } | nullSchema Builder API
IgniterCallerSchema
The root builder for defining your API contract.
IgniterCallerSchema.create()
.schema(key, schema) // Register reusable schemas
.path('/path', builder) // Define path with HTTP methods
.build(); // Build the final schema mapRegistering Reusable Schemas
Use .schema() to register schemas in a shared registry:
const schemas = IgniterCallerSchema.create()
.schema('User', z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
}))
.schema('PaginationMeta', z.object({
page: z.number(),
total: z.number(),
hasMore: z.boolean(),
}))
.path('/users/:id', (path) =>
path.get({
responses: {
200: path.ref('User').schema, // Reference by key
404: z.object({ message: z.string() }),
},
})
)
.build();Schema Wrappers
.ref(key) returns a helper with Zod-based wrappers:
| Method | Description | Example Output |
|---|---|---|
.schema | The schema as-is | UserSchema |
.array() | Array wrapper | z.array(UserSchema) |
.nullable() | Nullable wrapper | UserSchema.nullable() |
.optional() | Optional wrapper | UserSchema.optional() |
.record(keyType?) | Record wrapper | z.record(z.string(), UserSchema) |
.path('/users', (path) =>
path.get({
responses: {
// Array of users
200: path.ref('User').array(),
// Nullable response
401: path.ref('Error').nullable().schema,
},
})
)Defining Path Methods
Each path can define multiple HTTP methods. Each method accepts a config object with:
| Property | Type | Description |
|---|---|---|
request | StandardSchemaV1 | Schema for validating the request body |
responses | Record<number, StandardSchemaV1> | Status-code-keyed response schemas |
.path('/users/:id', (path) =>
path
.get({ responses: { 200: UserSchema } })
.put({
request: UpdateUserSchema,
responses: {
200: UserSchema,
404: z.object({ message: z.string() }),
},
})
.patch({
request: PartialUserSchema,
responses: { 200: UserSchema },
})
.delete({
responses: {
204: undefined, // No content
404: z.object({ message: z.string() }),
},
})
)Using Schemas
Basic Usage
Pass your built schemas to the caller builder:
const api = IgniterCaller.create()
.withBaseUrl('https://api.example.com')
.withSchemas(schemas, { mode: 'strict' })
.build();Inline Schemas
You can also define schemas directly, without building a separate registry:
const api = IgniterCaller.create()
.withSchemas({
'/users': {
GET: {
responses: { 200: z.array(UserSchema) },
},
},
'/users/:id': {
GET: {
responses: {
200: UserSchema,
404: ErrorSchema,
},
},
},
})
.build();Validation Modes
| Mode | Behavior |
|---|---|
'strict' | (Default) Rejects the request if the response doesn't match the schema. Returns a IGNITER_CALLER_RESPONSE_VALIDATION_FAILED error. |
'loose' | Logs a warning but still returns the data if validation fails. Useful during migration. |
'none' | Disables all runtime validation. TypeScript inference still works. |
// Strict mode (default) — fails on schema mismatch
const strict = IgniterCaller.create()
.withSchemas(schemas, { mode: 'strict' })
.build();
// Loose mode — warns but continues
const loose = IgniterCaller.create()
.withSchemas(schemas, { mode: 'loose' })
.build();Type Inference with $Infer
The .build() result includes non-enumerable $Infer and get helpers for extracting types:
const schemas = IgniterCallerSchema.create()
.path('/users/:id', (path) =>
path.get({
responses: {
200: UserSchema,
404: ErrorSchema,
},
})
)
.build();
// Extract types without requesting
type GetUserResponse = ReturnType<
typeof schemas.$Infer.Response<'/users/:id', 'GET', 200>
>;
// Type: { id: number; name: string; email: string }
type GetUserRequest = ReturnType<
typeof schemas.$Infer.Request<'/users/:id', 'GET'>
>;
// Runtime getters for schema objects
const pathSchemas = schemas.get.path('/users/:id');
const getEndpoint = schemas.get.endpoint('/users/:id', 'GET');
const requestSchema = schemas.get.request('/users/:id', 'GET');
const responseSchema = schemas.get.response('/users/:id', 'GET', 200);Available Inference Methods
| Method | Description |
|---|---|
$Infer.Path<'/path'> | All methods for a path |
$Infer.Endpoint<'/path', 'GET'> | Single endpoint config |
$Infer.Request<'/path', 'POST'> | Request body type |
$Infer.Response<'/path', 'GET', 200> | Response body for a status code |
$Infer.Responses<'/path', 'GET'> | All response statuses |
$Infer.Schema<'User'> | Registered schema by key |
Content Type Validation
Schema validation only runs for text-based content types:
application/jsontext/xml/application/xmltext/csv
Binary responses (Blobs, Streams, ArrayBuffers, FormData) are skipped for validation. Use .responseType<T>() for typing only:
// Binary response — no validation, typing only
const { data: file } = await api.get('/files/:id')
.responseType<Blob>()
.execute();Error Handling with Schemas
When validation fails in strict mode, you get a structured error:
const result = await api.get('/users/:id')
.params({ id: '1' })
.execute();
if (result.error && IgniterCallerError.is(result.error)) {
switch (result.error.code) {
case 'IGNITER_CALLER_RESPONSE_VALIDATION_FAILED':
// The server returned data that doesn't match the schema
console.error('API contract mismatch:', result.error.details);
break;
case 'IGNITER_CALLER_REQUEST_VALIDATION_FAILED':
// The request body failed validation
console.error('Invalid request:', result.error.details);
break;
case 'IGNITER_CALLER_SCHEMA_INVALID':
// Your schema definition has an error
console.error('Schema error:', result.error.message);
break;
}
}Schema Organization
For large APIs, split schemas across files:
import { IgniterCallerSchema } from '@igniter-js/caller';
import { userPaths } from './users';
import { productPaths } from './products';
export const schemas = IgniterCallerSchema.create()
// Register shared schemas
.schema('User', userSchema)
.schema('Product', productSchema)
// Compose paths from modules
.path('/users/:id', userPaths.getById)
.path('/users', userPaths.list)
.path('/users', userPaths.create)
.path('/products/:id', productPaths.getById)
.build();import { z } from 'zod';
import type { IgniterCallerSchemaPathBuilder } from '@igniter-js/caller';
export const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
export const userPaths = {
getById: (path: ReturnType<IgniterCallerSchemaPathBuilder['ref']> extends infer R ? any : never) => { /* ... */ },
// This pattern works well when using the builder with explicit composition
} as const;Alternative pattern
For simpler cases, just define your whole schema in one file. Split only when the file exceeds 200+ lines.
Best Practices
Schema Design Guidelines
- Use strict mode in production — catches API contract mismatches before they cause subtle bugs
- Use loose mode during development — helpful when iterating on APIs that aren't final
- Register shared schemas — avoid duplicating User, Error, and Pagination schemas across paths
- Define error schemas — always include error responses (400, 401, 404, 500) for comprehensive typing
- Use path parameters —
:idin URL patterns provides typed params and automatic matching - Keep schemas near the API client — co-locate in
lib/api/orlib/schemas/
Common Mistakes
- Don't forget that path must start with
/—'users'will throwIGNITER_CALLER_SCHEMA_INVALID - Don't define the same path+method twice — throws
IGNITER_CALLER_SCHEMA_DUPLICATE - Don't expect validation on binary responses — Blobs, streams, and files skip validation
- Don't use
withSchemas()without Zod installed — schema validation requires a StandardSchemaV1 library
Supported Schema Libraries
Caller uses the StandardSchemaV1 interface. Any library implementing this interface works:
| Library | Version | Notes |
|---|---|---|
| Zod | ≥ 4.2 | Fully supported. Used in all examples. |
| Valibot | ≥ 1.0 | Exports StandardSchemaV1 interface |
Custom schemas
You can implement your own schemas by conforming to the StandardSchemaV1 interface (~standard, ~validate). See the Standard Schema spec for details.
Next Steps
Retries
Handle transient failures with automatic retry logic and backoff strategies. Make your application resilient to network issues and server errors.
Real-World Examples
Production-ready patterns for common API workflows. Covers authentication, CRUD operations, file uploads, pagination, external API integration, and more.