Building Type-Safe APIs with itty-spec: A Contract-First Approach
Source: Dev.to
Introduction
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
- Seamless OpenAPI documentation generation
- Compatibility with edge computing environments
Traditional API development often suffers from:
- 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 a poor developer experience. itty-spec solves them by making schema definitions the single source of truth for routes, validation, types, and documentation.
Contract‑First Approach
itty-spec follows a contract‑first workflow:
- 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
Typed Request Objects
When you define a contract, TypeScript automatically infers the types for:
- Path parameters – extracted from 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; // string
const include = request.query.include; // enum | undefined
return request.json({ id: userId, name: 'John' });
},
},
});
The type system prevents accidental access to non‑existent properties and offers autocomplete for all available fields.
Validation Pipeline
itty-spec uses a middleware pipeline that validates incoming requests before they reach your handlers. Validation occurs in this order:
- Path parameters – extracted and validated against an optional
pathParamsschema - Query parameters – parsed and validated
- Headers – normalized and validated
- Request body – parsed from JSON and validated (for POST/PUT/PATCH)
If any step fails, the request is rejected with a 400 status code and detailed error information, never 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() }) },
},
},
});
When the request body doesn’t match the schema, validation fails automatically, allowing your handler to focus solely on business logic.
Vendor‑Agnostic Schema Support
itty-spec leverages the Standard Schema V1 specification, enabling you to use any schema library that implements it:
- Zod (v4) – 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 spec defines a unified interface (StandardSchemaV1) with:
~standard.vendor– identifies the schema library~standard.validate()– standardized validation method- Type inference capabilities through TypeScript
// 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 } },
},
});
This approach prevents lock‑in to a specific library and simplifies migration.
Automatic OpenAPI 3.1 Generation
itty-spec can generate a complete OpenAPI 3.1 specification directly from your contracts, eliminating manual documentation upkeep.
Key features of the OpenAPI generator
- Extracts schemas from all contract operations
- Converts
:parampath syntax to{param}(OpenAPI standard) - Maps response schemas to OpenAPI response objects
- Includes headers, query params, and request bodies
- Deduplicates schemas via a registry system
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 generated spec can be used for:
- Interactive documentation (Swagger UI, Stoplight Elements)
- Client SDK generation (openapi-generator, swagger-codegen)
- API testing tools (Postman, Insomnia)
- CI/CD validation
The output complies fully with OpenAPI 3.1 and works with any supporting tool.
Typed Response Helpers
itty-spec adds typed helper methods on the request object to ensure responses adhere to contract definitions:
request.json(body, status?, headers?)– JSON responses with type validationrequest.html(html, status?, headers?)– HTML responsesrequest.error(status, body, headers?)– Error responsesrequest.noContent(status)– 204 No Content responses
These helpers enforce:
- Only valid status codes defined in the contract can be used
- Response bodies match the schema for the given status code
- Headers conform to any optional header schema
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' });
}
return request.json({ id: user.id, name: user.name });
},
},
});
Conclusion
By making schemas the single source of truth, itty-spec delivers:
- Type safety across the entire request‑response lifecycle
- Automatic validation that protects against malformed inputs
- Zero‑maintenance documentation via OpenAPI generation
- Flexibility to choose any Standard Schema V1‑compatible library
Adopt itty-spec to streamline API development, reduce bugs, and improve developer experience in edge‑ready environments.