End-to-end type-safe OpenAPI-first APIs with minimal boilerplate.
Define your API contract once with Zod schemas, get full type inference for handlers, and generate OpenAPI specs automatically. Works with Hono, Express, and Fastify.
- Features
- Prerequisites
- Quick Start
- Framework Adapters
- Route Builder API
- Pagination & Filtering Helpers
- API Versioning
- Middleware
- Error Handling
- Authentication
- Response Headers
- OpenAPI Examples
- CLI Commands
- Comparison
- Packages
- Resources
- 🔒 Full Type Safety: From Zod schemas to handler implementations
- 📝 OpenAPI First: Auto-generate valid OpenAPI 3.0 / 3.1 specs
- 🔌 Framework Agnostic: Hono, Express, and Fastify adapters
- 🎯 Minimal Boilerplate: Define a CRUD API in ~30 lines
- 🔐 Auth Enforcement: Contract-driven authentication with pluggable verify functions
- 📋 Response Headers: Contract-defined response headers with typed handler support
- 🏗️ Hierarchical Middleware: Apply middleware at version, group, or route level
- 📦 First-class API Versioning: v1, v2, etc. built into the design
- 📄 Pagination & Filtering: Built-in schema factories for offset, cursor, and sort patterns
⚠️ Typed Error Responses: Pre-built error schemas with.withErrors()shorthand- 🔧 CLI Tools: Generate specs, client types, and scaffold new projects
- Node.js 20+ or Bun 1.0+
- TypeScript 5.5+ with strict mode enabled
- Package manager: npm, pnpm, yarn, or bun
- One of: Hono, Express, or Fastify
# Core package (required)
pnpm add @typeful-api/core zod
# Pick your framework adapter
pnpm add @typeful-api/hono hono @hono/zod-openapi
# or
pnpm add @typeful-api/express express
# or
pnpm add @typeful-api/fastify fastify
# Optional: CLI for spec generation
pnpm add -D @typeful-api/cliHere's the simplest possible API to get started:
// src/api.ts
import { defineApi, route } from '@typeful-api/core';
import { z } from 'zod';
export const api = defineApi({
v1: {
children: {
hello: {
routes: {
greet: route
.get('/')
.returns(z.object({ message: z.string() }))
.withSummary('Say hello'),
},
},
},
},
});// src/server.ts
import { Hono } from 'hono';
import { createHonoRouter } from '@typeful-api/hono';
import { api } from './api';
const router = createHonoRouter(api, {
v1: {
hello: {
greet: async () => ({ message: 'Hello, World!' }),
},
},
});
const app = new Hono();
app.route('/api', router);
export default app;Run with bun run src/server.ts or npx tsx src/server.ts, then visit http://localhost:3000/api/v1/hello.
For a more complete example with schemas, params, and authentication:
// src/api.ts
import { defineApi, route } from '@typeful-api/core';
import { z } from 'zod';
// Define your schemas
const ProductSchema = z.object({
id: z.uuid(),
name: z.string().min(1),
price: z.number().positive(),
});
const CreateProductSchema = ProductSchema.omit({ id: true });
const IdParamsSchema = z.object({
id: z.uuid(),
});
// Define your API contract
export const api = defineApi({
v1: {
children: {
products: {
routes: {
list: route.get('/').returns(z.array(ProductSchema)).withSummary('List all products'),
get: route
.get('/:id')
.params(IdParamsSchema)
.returns(ProductSchema)
.withSummary('Get a product by ID'),
create: route
.post('/')
.body(CreateProductSchema)
.returns(ProductSchema)
.withAuth('bearer')
.withSummary('Create a new product'),
delete: route
.delete('/:id')
.params(IdParamsSchema)
.returns(z.object({ success: z.boolean() }))
.withAuth('bearer')
.withSummary('Delete a product'),
},
},
},
},
});// src/server.ts
import { Hono, HTTPException } from 'hono';
import { createHonoRouter } from '@typeful-api/hono';
import { api } from './api';
// Define environment types
type ProductsEnv = {
Bindings: { DATABASE_URL: string };
Variables: { db: Database };
};
type Envs = {
v1: {
products: ProductsEnv;
};
};
// Create router with fully typed handlers
const router = createHonoRouter<typeof api, Envs>(api, {
v1: {
products: {
list: async ({ c }) => {
const db = c.get('db');
return await db.products.findMany();
},
get: async ({ c, params }) => {
const db = c.get('db');
const product = await db.products.find(params.id);
if (!product) throw new HTTPException(404);
return product;
},
create: async ({ c, body }) => {
const db = c.get('db');
return await db.products.create({
id: crypto.randomUUID(),
...body,
});
},
delete: async ({ c, params }) => {
const db = c.get('db');
await db.products.delete(params.id);
return { success: true };
},
},
},
});
const app = new Hono();
app.route('/api', router);
export default app;As your API grows, you'll want handlers in their own files. typeful-api makes this easy — derive handler types from the contract and use them to type standalone functions:
// src/types.ts — derive handler types from the contract
import type { InferHonoHandlersWithVars } from '@typeful-api/hono';
import type { api } from './api';
type AppHandlers = InferHonoHandlersWithVars<typeof api, { db: Database }>;
// Index into the handler map to get types for each group
export type ProductHandlers = AppHandlers['v1']['products'];// src/handlers/products.ts — fully typed, autocompletion works
import type { ProductHandlers } from '../types';
export const list: ProductHandlers['list'] = async ({ c }) => {
const db = c.get('db');
return await db.products.findMany();
};
export const get: ProductHandlers['get'] = async ({ c, params }) => {
const db = c.get('db');
const product = await db.products.find(params.id);
if (!product) throw new HTTPException(404);
return product;
};
export const create: ProductHandlers['create'] = async ({ c, body }) => {
const db = c.get('db');
return await db.products.create({ id: crypto.randomUUID(), ...body });
};// src/server.ts — import and wire up
import { createHonoRouter } from '@typeful-api/hono';
import { api } from './api';
import * as products from './handlers/products';
const router = createHonoRouter<typeof api, { db: Database }>(api, {
v1: {
products: {
list: products.list,
get: products.get,
create: products.create,
delete: products.deleteProduct,
},
},
});The type flows automatically: contract → InferHonoHandlersWithVars → indexed handler type → typed function. Change a Zod schema and every handler's types update with it.
# Using Bun
bun run src/server.ts
# Using Node.js with tsx
npx tsx src/server.ts
# Or add to package.json scripts
# "dev": "bun run --watch src/server.ts"Your API is now available at:
GET /api/v1/products- List all productsGET /api/v1/products/:id- Get a productPOST /api/v1/products- Create a product (requires auth)DELETE /api/v1/products/:id- Delete a product (requires auth)
# Using CLI
typeful-api generate-spec \
--contract ./src/api.ts \
--out ./openapi.json \
--title "My API" \
--api-version "1.0.0"import { createHonoRouter, WithVariables } from '@typeful-api/hono';
// Compose context types
type BaseEnv = { Bindings: Env };
type WithDb = WithVariables<BaseEnv, { db: Database }>;
type WithAuth = WithVariables<WithDb, { user: User }>;
const router = createHonoRouter<
typeof api,
{
v1: {
products: WithDb;
users: WithAuth;
};
}
>(api, handlers);import { createExpressRouter, getLocals } from '@typeful-api/express';
const router = createExpressRouter(api, {
v1: {
middleware: [corsMiddleware],
products: {
middleware: [dbMiddleware],
list: async ({ req }) => {
const { db } = getLocals<{ db: Database }>(req);
return await db.products.findMany();
},
},
},
});
app.use('/api', router);import { createFastifyPlugin, getLocals } from '@typeful-api/fastify';
fastify.register(
createFastifyPlugin(api, {
v1: {
preHandler: [dbPreHandler],
products: {
list: async ({ request }) => {
const { db } = getLocals<{ db: Database }>(request);
return await db.products.findMany();
},
},
},
}),
{ prefix: '/api' },
);The route builder provides a fluent API for defining routes:
import { route } from '@typeful-api/core';
import { z } from 'zod';
// GET request with query params
route
.get('/search')
.query(z.object({ q: z.string(), page: z.number().optional() }))
.returns(SearchResultSchema)
.withSummary('Search products');
// POST request with body and auth
route
.post('/products')
.body(CreateProductSchema)
.returns(ProductSchema)
.withAuth('bearer')
.withTags('products', 'write')
.withSummary('Create a product');
// With path params and typed error responses
route
.get('/products/:id')
.params(z.object({ id: z.uuid() }))
.returns(ProductSchema)
.withErrors(404, 401);
// Mark as deprecated
route.get('/legacy/products').returns(z.array(ProductSchema)).markDeprecated();
// With request/response examples for OpenAPI docs
route
.post('/products')
.body(CreateProductSchema)
.returns(ProductSchema)
.withExamples({
requestBody: {
basic: { summary: 'Simple product', value: { name: 'Widget', price: 9.99 } },
},
responses: {
200: { created: { summary: 'Created', value: { id: '1', name: 'Widget', price: 9.99 } } },
},
});
// With typed response headers
route.get('/products').returns(z.array(ProductSchema)).withResponseHeaders({
'X-Total-Count': z.string(),
'X-Request-Id': z.string(),
});Built-in Zod schema factories for common API patterns — no more copy-pasting pagination schemas across projects:
import {
paginationQuery,
cursorQuery,
sortQuery,
paginated,
cursorPaginated,
} from '@typeful-api/core';
// Offset-based pagination query: { page, limit }
const query = paginationQuery(); // defaults: page=1, limit=20, maxLimit=100
const customQuery = paginationQuery({ defaultLimit: 50, maxLimit: 200 });
// Cursor-based pagination query: { cursor?, limit }
const cursor = cursorQuery();
// Sort query with allowed fields: { sortBy?, sortOrder? }
const sort = sortQuery(['name', 'createdAt', 'price'] as const);
// Paginated response wrapper: { items: T[], total, page, limit, totalPages }
const listRoute = route.get('/').query(paginationQuery()).returns(paginated(ProductSchema));
// Cursor-based response: { items: T[], nextCursor, hasMore }
const feedRoute = route.get('/feed').query(cursorQuery()).returns(cursorPaginated(PostSchema));All query helpers use z.coerce.number() for automatic HTTP query string conversion, so ?page=2&limit=10 works out of the box. The generated OpenAPI spec includes all defaults and constraints.
Version your API with automatic path prefixing:
const api = defineApi({
v1: {
children: {
products: { routes: v1ProductRoutes },
},
},
v2: {
children: {
products: { routes: v2ProductRoutes },
},
},
});
// Results in:
// GET /api/v1/products
// GET /api/v2/productsApply middleware at different levels:
const router = createHonoRouter(api, {
v1: {
middlewares: [corsMiddleware], // All v1 routes
products: {
middlewares: [dbMiddleware], // All product routes
list: handler,
create: handler,
},
admin: {
middlewares: [authMiddleware], // All admin routes
users: {
middlewares: [adminOnlyMiddleware], // All user admin routes
list: handler,
},
},
},
});typeful-api automatically validates requests against your Zod schemas. Invalid requests return a 400 Bad Request with validation details.
When a request fails validation, the response includes:
{
"success": false,
"error": {
"issues": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["name"],
"message": "Required"
}
]
}
}Use .withErrors() to add typed error responses with a single method call:
// Add 404 and 401 error responses — schemas and OpenAPI descriptions are automatic
route
.get('/:id')
.params(IdParamsSchema)
.returns(ProductSchema)
.withErrors(404, 401)
.withSummary('Get a product');Supported status codes: 400, 401, 403, 404, 409, 422, 429, 500.
Each error schema uses z.literal() codes (e.g., 'NOT_FOUND') for client-side discriminated unions. You can also use the individual factories directly:
import { notFoundError, commonErrors, errorSchema } from '@typeful-api/core';
// Use pre-built error schemas with .withResponses()
route.get('/:id').returns(ProductSchema).withResponses({
404: notFoundError(),
401: unauthorizedError(),
});
// Or batch them with commonErrors()
route.get('/:id').returns(ProductSchema).withResponses(commonErrors(404, 401));
// Create custom error schemas
const RateLimitError = errorSchema('RATE_LIMITED', 'Too many requests');For fully custom error shapes, use .withResponses() directly:
const NotFoundError = z.object({
error: z.literal('not_found'),
message: z.string(),
});
route
.get('/:id')
.params(IdParamsSchema)
.returns(ProductSchema)
.withResponses({ 404: NotFoundError })
.withSummary('Get a product');Use framework-specific exceptions:
// Hono
import { HTTPException } from 'hono';
get: async ({ c, params }) => {
const product = await db.products.find(params.id);
if (!product) {
throw new HTTPException(404, { message: 'Product not found' });
}
return product;
};The .withAuth() method marks routes as requiring authentication. This both documents the security requirement in the OpenAPI spec and enables runtime enforcement via the auth adapter option.
route
.post('/products')
.body(CreateProductSchema)
.returns(ProductSchema)
.withAuth('bearer') // Requires Bearer token
.withSummary('Create a product');Pass an auth config to any adapter to automatically enforce authentication on routes that declare .withAuth(). Credentials are extracted and verified before the handler runs:
// Works with createExpressRouter, createFastifyPlugin, and createHonoRouter
const router = createExpressRouter(api, handlers, {
auth: {
bearer: async ({ token }) => {
const user = await verifyJWT(token);
return { id: user.sub, role: user.role };
},
apiKey: async ({ key }) => {
const apiKey = await db.apiKeys.findByKey(key);
if (!apiKey) throw new Error('Invalid API key');
return { id: apiKey.userId, role: 'service' };
},
basic: async ({ username, password }) => {
const user = await db.users.verify(username, password);
if (!user) throw new Error('Invalid credentials');
return { id: user.id, role: user.role };
},
},
});Routes with auth: 'none' or no auth declaration skip enforcement entirely. When a verify function throws, the adapter returns a 401 response automatically.
You can also handle auth through framework-native middleware for full control:
const router = createHonoRouter(api, {
v1: {
middlewares: [authMiddleware], // Apply to all v1 routes
products: {
list: handler,
create: handler,
},
},
});'bearer'- Bearer token authentication (extracted fromAuthorization: Bearer <token>)'basic'- Basic HTTP authentication (extracted fromAuthorization: Basic <base64>)'apiKey'- API key (extracted fromX-API-Keyheader)'none'- Explicitly public (skips enforcement)
These map to OpenAPI security schemes in the generated spec.
Define typed response headers in your contract and return them from handlers:
const listProducts = route
.get('/products')
.query(paginationQuery())
.returns(paginated(ProductSchema))
.withResponseHeaders({
'X-Total-Count': z.string(),
'Cache-Control': z.string(),
});import { withHeaders } from '@typeful-api/core';
const handler = async ({ query }) => {
const { items, total } = await db.products.list(query);
return withHeaders(
{
items,
total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(total / query.limit),
},
{ 'X-Total-Count': String(total), 'Cache-Control': 'max-age=60' },
);
};Headers are emitted in the OpenAPI spec and set on HTTP responses by all adapters. Handlers that don't use withHeaders() continue to work as before — plain return values are sent without additional headers.
Add request/response examples to improve generated API documentation:
route
.post('/products')
.body(CreateProductSchema)
.returns(ProductSchema)
.withErrors(400, 409)
.withExamples({
requestBody: {
simple: { summary: 'Basic product', value: { name: 'Widget', price: 9.99 } },
premium: { summary: 'Premium product', value: { name: 'Pro Widget', price: 99.99 } },
},
responses: {
200: { created: { value: { id: '1', name: 'Widget', price: 9.99 } } },
400: { invalid: { value: { code: 'BAD_REQUEST', message: 'Invalid input' } } },
},
});Examples appear in Swagger UI, Redocly, and other OpenAPI tools. They also enable API mocking with tools like Prism.
# Scaffold a new project from a template
typeful-api init --template hono
typeful-api init --template express --dir ./my-api --name my-api
typeful-api init --template fastify
# Generate OpenAPI spec from contract
typeful-api generate-spec \
--contract ./src/api.ts \
--out ./openapi.json \
--title "My API" \
--api-version "1.0.0" \
--server https://api.example.com
# Generate TypeScript client types
typeful-api generate-client \
--spec ./openapi.json \
--out ./src/client.d.ts
# Watch mode for development
typeful-api generate-spec --contract ./src/api.ts --watchThe init command generates a ready-to-run project with package.json, tsconfig.json, typed API contract using pagination and error helpers, and a framework-specific server entry point. Available templates: hono (default), express, fastify.
| **typeful-api** | ts-rest | @hono/zod-openapi | tRPC | Elysia | |
|---|---|---|---|---|---|
| Approach | Contract-first | Contract-first | Route-first | Server-first RPC | Server-first |
| Validation | Zod | Standard Schema | Zod | Any (Zod common) | TypeBox / Standard Schema |
| OpenAPI generation | ✅ Portable | ✅ Portable | ✅ Portable | ✅ Via plugin | |
| Framework support | Hono, Express, Fastify | Express, Fastify, Next.js, NestJS | Hono only | Express, Fastify, Next.js, Lambda | Bun, Node.js, Deno |
| API versioning | ✅ First-class | ❌ Manual | ❌ Manual | ❌ Manual | ❌ Manual |
| Hierarchical middleware | ✅ Native | ✅ Via Hono | ✅ Type-safe pipes | ✅ Guard system | |
| Handler decoupling | ✅ Typed separate files | ✅ With caveats | ✅ Routes + handlers | ✅ Standard | |
| Built-in client | ✅ CLI generation | ✅ Fetch-based | ❌ Use external | ✅ Type-inferred | ✅ Eden Treaty |
| REST / OpenAPI native | ✅ | ✅ | ✅ | ❌ Custom RPC | ✅ |
How they differ:
- tRPC is the most popular option for TypeScript monorepos, but uses a custom RPC protocol — not REST. tRPC v11 ships native OpenAPI generation (alpha), but it remains RPC-first — REST/OpenAPI is a secondary output, not the core design.
- ts-rest is the closest alternative to typeful-api. It shares the contract-first Zod approach but lacks built-in API versioning and hierarchical middleware.
- @hono/zod-openapi is excellent if you're committed to Hono. typeful-api builds on top of it for Hono and extends the same ideas to Express and Fastify.
- Elysia is a fast full framework with great DX. Since v1.2 it supports Node.js and Deno via adapters (Bun remains the primary runtime), but it is not contract-first.
| Package | Description |
|---|---|
@typeful-api/core |
Framework-agnostic core with route builder and spec generation |
@typeful-api/hono |
Hono adapter with OpenAPI integration |
@typeful-api/express |
Express adapter with validation middleware |
@typeful-api/fastify |
Fastify adapter with preHandler hooks |
@typeful-api/cli |
CLI for spec and client generation |
- Examples - Full working examples for each framework
- GitHub Issues - Report bugs or request features
- Changelog - Version history and updates
MIT