Building Type-Safe APIs with itty-spec: A Contract-First Approach
itty-spec is a powerful library that brings type-safe, contract-first API development to itty-router. By defining your API contracts using standard schema libraries, you get automatic validation, full TypeScript type inference, and seamless OpenAPI documentation generation—all while maintaining compatibility with edge computing environments.
The Problem: Building APIs Without Contracts
Traditional API development often involves:
- Manual validation scattered across route handlers
- Type definitions that drift from actual runtime behavior
- Inconsistent error handling for invalid requests
- Outdated documentation that requires manual maintenance
- No runtime guarantee that handlers match their documented contracts
These issues lead to bugs, security vulnerabilities, and poor developer experience. itty-spec solves these problems by making your schema definitions the single source of truth for routes, validation, types, and documentation.
Core Philosophy: Contract-First Development
itty-spec follows a contract-first approach where you define your API's structure and validation rules upfront using standard schema libraries. The contract becomes the foundation that drives:
- Route registration - Automatically creates routes from contract definitions
- Request validation - Validates all incoming data against schemas
- Type inference - Provides full TypeScript types for handlers
- Response validation - Ensures handlers return valid responses
- Documentation generation - Automatically creates OpenAPI specifications
Key Features and Capabilities
1. Type-Safe Handlers
One of the most powerful features of itty-spec is how it provides fully typed request objects to your handlers. When you define a contract, TypeScript automatically infers the types for:
-
Path parameters - Extracted from route patterns like
/users/:id - Query parameters - Typed and validated query strings
- Request headers - Validated header objects
- Request body - Typed request payloads
import { createContract } from 'itty-spec';
import { z } from 'zod';
const contract = createContract({
getUser: {
path: '/users/:id',
query: z.object({
include: z.enum(['posts', 'comments']).optional(),
}),
headers: z.object({
'x-api-key': z.string(),
}),
responses: {
200: { body: z.object({ id: z.string(), name: z.string() }) },
},
},
});
// In your handler, everything is fully typed!
const router = createRouter({
contract,
handlers: {
getUser: async (request) => {
// request.params.id is typed as string
// request.query.include is typed as 'posts' | 'comments' | undefined
// request.validatedHeaders['x-api-key'] is typed as string
const userId = request.params.id; // TypeScript knows this is a string
const include = request.query.include; // Type-safe enum access
return request.json({ id: userId, name: 'John' });
},
},
});
The type system ensures you can't accidentally access properties that don't exist and provides autocomplete for all available fields.
2. Validation-Driven Contracts
itty-spec uses a middleware pipeline that automatically validates all incoming requests before they reach your handlers. The validation happens in a specific order:
-
Path parameters - Extracted from the URL and validated against optional
pathParamsschema - Query parameters - Parsed from the query string and validated
- Headers - Normalized and validated
- Request body - Parsed from JSON and validated (for POST/PUT/PATCH requests)
If validation fails at any step, the request is rejected with a 400 status code and detailed error information, without ever reaching your handler code.
const contract = createContract({
createUser: {
path: '/users',
method: 'POST',
request: z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(120),
}),
responses: {
200: { body: z.object({ id: z.string(), name: z.string() }) },
400: { body: z.object({ error: z.string(), details: z.any() }) },
},
},
});
// If the request body doesn't match the schema, validation fails automatically
// Your handler only receives validated, typed data
This means your handlers can focus on business logic, knowing that all input data already conforms to your contract's requirements.
3. Standard Schema Spec Support
itty-spec leverages the Standard Schema V1 specification to provide vendor-agnostic schema support. This means you can use any schema library that implements the Standard Schema spec, including:
- Zod (v4) - Currently fully supported with OpenAPI generation
- Valibot - Compatible with validation (OpenAPI support planned)
- ArkType - Compatible with validation (OpenAPI support planned)
- Any other library implementing Standard Schema V1
The Standard Schema spec provides a unified interface (StandardSchemaV1) that includes:
-
~standard.vendor- Identifies the schema library -
~standard.validate()- Standardized validation method - Type inference capabilities through TypeScript
This vendor-agnostic approach means you're not locked into a specific schema library and can migrate between them without changing your contract definitions.
// Works with Zod
import { z } from 'zod';
const zodSchema = z.object({ name: z.string() });
// Works with Valibot (once supported)
import * as v from 'valibot';
const valibotSchema = v.object({ name: v.string() });
// Both can be used in contracts
const contract = createContract({
endpoint: {
path: '/test',
request: zodSchema, // or valibotSchema
responses: { 200: { body: zodSchema } },
},
});
4. Automated Documentation Generation
One of the most powerful features of itty-spec is its ability to automatically generate OpenAPI 3.1 specifications from your contracts. This eliminates the need to manually maintain API documentation—your schema definitions become the documentation.
The OpenAPI generator:
- Extracts schemas from all contract operations
-
Converts path formats from
:paramto{param}(OpenAPI standard) - Maps response schemas to OpenAPI response objects
- Includes headers, query params, and request bodies in the specification
- Deduplicates schemas using a registry system to avoid duplication
import { createOpenApiSpecification } from 'itty-spec/openapi';
import { contract } from './contract';
const openApiSpec = createOpenApiSpecification(contract, {
title: 'User Management API',
version: '1.0.0',
description: 'A comprehensive API for managing users',
servers: [
{ url: 'https://api.example.com', description: 'Production' },
{ url: 'https://staging-api.example.com', description: 'Staging' },
],
});
// The spec can be used for:
// - Serving interactive documentation (Swagger UI, Stoplight Elements)
// - Generating client SDKs (openapi-generator, swagger-codegen)
// - API testing tools (Postman, Insomnia)
// - CI/CD validation
The generated OpenAPI spec is fully compliant with OpenAPI 3.1 and can be used with any tool that supports the specification.
5. Type-Safe Response Helpers
itty-spec provides typed response helper methods on the request object that ensure your responses match your contract definitions:
-
request.json(body, status?, headers?)- Creates JSON responses with type validation -
request.html(html, status?, headers?)- Creates HTML responses -
request.error(status, body, headers?)- Creates error responses -
request.noContent(status)- Creates 204 No Content responses
These helpers use TypeScript's type system to ensure:
- Only valid status codes from your contract can be used
- Response bodies match the schema for the given status code
- Headers match the optional header schema for the response
const contract = createContract({
getUser: {
path: '/users/:id',
responses: {
200: { body: z.object({ id: z.string(), name: z.string() }) },
404: { body: z.object({ error: z.string() }) },
},
},
});
const router = createRouter({
contract,
handlers: {
getUser: async (request) => {
const user = await findUser(request.params.id);
if (!user) {
// TypeScript ensures this matches the 404 response schema
return request.error(404, { error: 'User not found' });
}
// TypeScript ensures this matches the 200 response schema
return request.json({ id: user.id, name: user.name });
},
},
});
If you try to return an invalid status code or a body that doesn't match the schema, TypeScript will catch the error at compile time.
Real-World Example
Let's look at a complete example that demonstrates all these features working together:
import { createContract, createRouter } from 'itty-spec';
import { createOpenApiSpecification } from 'itty-spec/openapi';
import { z } from 'zod';
// Define schemas
const UserSchema = z.object({
id: z.uuid(),
name: z.string().min(1),
email: z.string().email(),
createdAt: z.string().datetime(),
});
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
const ListUsersQuerySchema = z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
search: z.string().optional(),
});
// Define the contract
const contract = createContract({
listUsers: {
path: '/users',
method: 'GET',
query: ListUsersQuerySchema,
headers: z.object({
'x-api-key': z.string(),
}),
responses: {
200: {
body: z.object({
users: z.array(UserSchema),
total: z.number(),
page: z.number(),
limit: z.number(),
}),
},
},
},
createUser: {
path: '/users',
method: 'POST',
request: CreateUserSchema,
headers: z.object({
'content-type': z.literal('application/json'),
}),
responses: {
201: { body: UserSchema },
400: { body: z.object({ error: z.string(), details: z.any() }) },
},
},
getUser: {
path: '/users/:id',
method: 'GET',
pathParams: z.object({
id: z.string().uuid(),
}),
responses: {
200: { body: UserSchema },
404: { body: z.object({ error: z.string() }) },
},
},
});
// Create the router with handlers
const router = createRouter({
contract,
handlers: {
listUsers: async (request) => {
// All types are inferred from the contract
const { page, limit, search } = request.query;
const apiKey = request.validatedHeaders['x-api-key'];
// Business logic here
const users = await findUsers({ page, limit, search, apiKey });
return request.json({
users: users.items,
total: users.total,
page,
limit,
});
},
createUser: async (request) => {
// request.validatedBody is fully typed as CreateUserSchema
const { name, email } = request.validatedBody;
const user = await createUser({ name, email });
// TypeScript ensures the response matches UserSchema
return request.json({
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
}, 201);
},
getUser: async (request) => {
// request.params.id is typed as string (from uuid schema)
const user = await findUserById(request.params.id);
if (!user) {
return request.error(404, { error: 'User not found' });
}
return request.json({
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
});
},
},
});
// Generate OpenAPI documentation
const openApiSpec = createOpenApiSpecification(contract, {
title: 'User Management API',
version: '1.0.0',
description: 'API for managing users',
servers: [{ url: 'https://api.example.com' }],
});
// Export for use in Cloudflare Workers, Node.js, Bun, etc.
export default {
fetch: router.fetch,
};
Architecture: How It Works
Under the hood, itty-spec uses a sophisticated middleware system that:
- Registers routes - Automatically creates routes from contract definitions
- Validates requests - Middleware pipeline validates path params, query, headers, and body
- Attaches types - Extends request objects with typed properties
- Provides helpers - Adds typed response helper methods
- Formats responses - Converts contract response objects to HTTP responses
- Handles errors - Catches validation errors and formats them appropriately
The middleware execution order ensures that validation happens before your handlers run, and type information flows through the entire request lifecycle.
Benefits Summary
Using itty-spec provides several key benefits:
- Type Safety - Catch errors at compile time, not runtime
- Reduced Boilerplate - Define routes, validation, and types in one place
- Automatic Validation - No need to manually validate requests
- Up-to-Date Documentation - OpenAPI specs generated from your contracts
- Vendor Flexibility - Use any Standard Schema V1 compatible library
- Edge Compatible - Works everywhere itty-router works (Cloudflare Workers, Node.js, Bun, etc.)
- Better DX - Full autocomplete and type checking in your IDE
Getting Started
To get started with itty-spec:
npm install itty-spec@latest zod
# or
pnpm add itty-spec@latest zod
Then define your contract and create your router:
import { createContract, createRouter } from 'itty-spec';
import { z } from 'zod';
const contract = createContract({
hello: {
path: '/hello',
query: z.object({ name: z.string().default('World') }),
responses: {
200: { body: z.object({ message: z.string() }) },
},
},
});
const router = createRouter({
contract,
handlers: {
hello: async (request) => {
return request.json({
message: `Hello, ${request.query.name}!`,
});
},
},
});
export default { fetch: router.fetch };
Conclusion
itty-spec brings contract-first development to the itty-router ecosystem, providing a powerful yet simple way to build type-safe APIs. By leveraging standard schemas and automatic validation, it eliminates many common sources of bugs while improving developer experience and maintaining up-to-date documentation.
Whether you're building APIs for Cloudflare Workers, Node.js, or Bun, itty-spec gives you the tools you need to build robust, well-documented, and type-safe APIs with minimal boilerplate.
For more information, visit the GitHub repository.
Top comments (1)
Very useful package. It will reduce lot of time in development.