https://strblr.github.io/zodpress
Tiny superset of Express for strongly typed, contract-first API development. Supports OpenAPI, request validation, nested routers, and regular Express for incremental adoption.
Install Zodpress along with its peer dependencies:
npm install zodpress express zod @asteasolutions/zod-to-openapi
Zodpress (zod + express) brings four key features to the table:
- Strongly typed Express - Define an API contract and get strongly typed request handlers. If TypeScript is happy, your API will be happy.
- Request validation - Zodpress validates requests against your contract using Zod. Support for headers, path params, query params, and body validation.
- OpenAPI support - Generate an OpenAPI document directly from your contracts, without duplicating your source of truth.
- Incremental adoption - Zodpress is fully compatible with regular Express. Zodpress apps and routers are just Express apps and routers with a
z
property.
Let's also add that it's extremely tiny (2kb gzipped) and has 100% test coverage.
The cornerstone of Zodpress is a contract. In it, you define your endpoints, methods, request and response schemas, as well as other metadata like tags, summary, etc.
import { zodpress } from "zodpress";
const contract = zodpress.contract({
get: {
"/todo/:id": {
summary: "Get a todo",
responses: {
404: z.literal("Not found"),
200: z.object({
id: z.string(),
title: z.string()
})
}
}
}
});
You then create apps and routers like you normally would, except that you pass it the contract. All the methods you're used to are still there, but now you have an extra z
property where you can attach strongly typed handlers:
const app = zodpress(contract);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.z.get("/todo/:id", (req, res) => {
res.status(200).json({
id: req.params.id,
title: "Todo"
});
});
Here is a sample of what I mean by strongly typed:
Headers, path params, query params, and request body will automatically be validated against Zod schemas defined in your contract. If there is a validation error, the default behavior is to send a 400 response with an error report. This behavior can be customized on a contract or route level. Simple example:
const contract = zodpress.contract({
post: {
"/todo": {
body: z.object({ title: z.string() })
}
}
});
Let's make a faulty request:
curl localhost:3000/todo -H "Content-Type: application/json" -d '{ "title": 42 }'
{
"name": "ValidationError",
"bodyErrors": [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": ["title"],
"message": "Expected string, received number"
}
]
}
Zodpress can generate an OpenAPI document from your contract. You're then free to serve it any way you want. It's also fully customizable if you need special OpenAPI fields, custom components, security schemes, etc. More on that in the docs. Below is a simple example featuring swagger-ui-express:
const openApiDocument = app.z.openapi.generate({
openapi: "3.0.0",
info: {
version: "1.0.0",
title: "My API",
description: "This is the API"
}
});
app.use("/docs", swagger.serve, swagger.setup(openApiDocument));
app.get("/openapi.json", (_, res) => {
res.json(openApiDocument);
});
Zodpress is fully compatible with regular Express, because it is just regular Express with an extra z
property. So regular use
, get
, post
, http.createServer(app)
, and everything you can imagine will work as expected. Zodpress-specific features live under the z
property of apps and routers. Converting a regular Express app simply involves replacing express()
with zodpress(contract)
and then gradually moving your routes to the z
property—while still allowing regular Express routes to coexist.
const app = zodpress(contract);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.get("/regular-route", (req, res) => {
// Weakly typed vanilla express
res.status(200).json({
message: "Hello, world!"
});
});
app.z.get("/todo/:id", (req, res) => {
// Strongly typed zodpress 💪
res.status(200).json({
id: req.params.id,
title: "Todo"
});
});
To create an app, call the zodpress
function where you would normally call express()
, and pass it a contract:
import { zodpress } from "zodpress";
const app = zodpress({
/* contract */
});
All Zodpress does here is create an actual Express app, attach a z
property to it and return the app. All features that are specific to this package live under app.z
. Since app
is just a regular Express app, you can use all the methods you already know:
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static("public"));
app.listen(3000, () => {
console.log("Server is running on port 3000");
});
Contracts are plain objects that define everything about your API, including routes, methods, request and response schemas, as well as other metadata like tags, summary, etc. They are the central piece of Zodpress. A contract is bound to an app or router instance. For modular servers with multiple routers, each router will have its own contract. Type-wise or runtime-wise, a contract does not affect anything outside of the app or router it is attached to.
Out of the box, contracts enable:
- Strongly typed request handlers
- Request validation
- Support for OpenAPI documents generation
TypeScript needs to understand your contracts as deep object literals for type inference to work. It is therefore recommended to either pass them directly to zodpress
/ zodpress.Router
or use the zodpress.contract
helper function if you want to define them separately. This also ensures that they conform to the contract interface.
const contract = zodpress.contract({
/* contract */
});
const app = zodpress(contract);
A contract is composed of a common config and route configs. Here is an example:
import { zodpress } from "zodpress";
import { z } from "zod";
const contract = zodpress.contract({
// Common config
tags: "todos",
commonResponses: {
500: z.literal("Internal error")
},
get: {
"/todos/:id": {
// Route config
summary: "Get a todo",
responses: {
404: z.literal("Not found"),
200: z.object({ id: z.string(), title: z.string() })
}
},
"/todos": {
// Route config
summary: "Get all todos",
query: z.object({
offset: z.number().optional(),
limit: z.number().optional()
}),
responses: {
200: z.array(z.object({ id: z.string(), title: z.string() }))
}
}
},
post: {
"/todos": {
// Route config
summary: "Create a todo",
headers: z.object({
authorization: z.string().regex(/^Bearer /)
}),
body: z.object({
title: z.string()
}),
responses: {
401: z.literal("Unauthorized"),
201: z.object({ id: z.string() })
}
}
}
});
See the API reference for more information.
Type-safe request handlers are added to your apps and routers by calling app.z.get
, app.z.post
, app.z.put
, app.z.patch
, or app.z.delete
instead of the regular app.get
, app.post
, etc. (which still work as expected).
app.z.get("/todos/:id", (req, res) => {
res.status(200).json({
id: req.params.id,
title: "Todo"
});
});
TypeScript will correctly infer the types of headers, path params, query params, request body, response codes and response bodies from your contract. At runtime, Zod will validate headers, path params, query params, and request body as long as they are defined in the contract.
For modularity, you can define a request handler as a standalone function outside of an app.z[method]
call. To infer the handler type from the app or router, use the inferHandler
type helper. To infer it from the contract itself, use the RequestHandler
type.
import type { inferHandler, RequestHandler } from "zodpress";
const auth: inferHandler<typeof app, "post", "/todos"> = (req, res) => {
/* ... */
};
const createTodo: RequestHandler<typeof contract, "post", "/todos"> = () => {
/* ... */
};
app.z.post("/todos", auth, createTodo);
In Express, routers are used to create modular route handlers. Think of it as sub-apps. Similar to how you create apps, call the zodpress.Router
function where you would normally call express.Router()
, and pass it a contract:
const router = zodpress.Router({
/* contract */
});
Like apps, routers have a z
property for defining type-safe handlers. This allows you to split your API into multiple routers, each in its own file with its own contract. Each router's contract only needs to define its own routes - you don't need to include those routes in the main app's contract or any parent router's contract.
// user.api.ts
const userRouter = zodpress.Router({
/* user contract */
});
// todo.api.ts
const todoRouter = zodpress.Router({
/* todo contract */
});
// app.ts
const app = zodpress({});
app.use(express.json());
app.use(userRouter);
app.use(todoRouter);
To validate request headers, use the headers
property of route configs in your contract. The schema has to be a Zod object. Since headers often contain many fields (most of which aren’t critical), the validated headers are shallowly merged with the original ones. This ensures that any headers not defined in the schema remain accessible at runtime. This also means that it wouldn't make sense to use strict
object schemas here unless you have a good reason.
const app = zodpress({
post: {
"/todos": {
headers: z.object({
authorization: z.string().regex(/^Bearer /)
})
}
}
});
app.z.post("/todos", (req, res) => {
const token = req.headers.authorization; // string
const other = req.headers.other; // string | string[] | undefined
// ...
});
TypeScript will automatically infer path param types from the path itself:
app.z.get("/todos/:id", (req, res) => {
const id = req.params.id; // string
const other = req.params.other; // Error: Property 'other' does not exist [...]
// ...
});
If you need to validate path params against a schema, you can use the params
property. The schema has to be a Zod object.
const app = zodpress({
get: {
"/todos/:id": {
params: z.object({ id: z.string().uuid() })
}
}
});
Query params can be validated against a schema using the query
property of route configs in your contract. The schema has to be a Zod object.
const app = zodpress({
get: {
"/todos": {
query: z.object({
offset: z.string().optional(),
limit: z.string().optional()
})
}
}
});
app.z.get("/todos", (req, res) => {
const offset = req.query.offset; // string | undefined
const limit = req.query.limit; // string | undefined
const other = req.query.other; // Error: Property 'other' does not exist [...]
// ...
});
To validate request bodies, use the body
property in your route configs. The schema can be of any Zod type. When generating OpenAPI documents, please refer to zod-to-openapi's list of supported Zod types.
const app = zodpress({
post: {
"/todos": {
body: z.object({ title: z.string().min(1) })
}
}
});
app.use(express.json()); // Don't forget this when dealing with JSON bodies
app.z.post("/todos", (req, res) => {
const title = req.body.title; // string
const other = req.body.other; // Error: Property 'other' does not exist [...]
// ...
});
Response schemas are not used for runtime validation (yet at least). Their current purpose is twofold:
- Strong typing of status codes and response bodies in request handlers
- Generating OpenAPI documents with response documentation
Use the responses
property in your route configs and map status codes to response schemas:
const app = zodpress({
get: {
"/todos/:id": {
responses: {
404: z.literal("Not found"),
200: z.object({ id: z.string(), title: z.string() })
}
}
}
});
app.z.get("/todos/:id", (req, res) => {
res.status(404).send("Not found"); // Ok
// Error: Argument of type 'Other' is not assignable to parameter of type 'Not found'
res.status(404).send("Other");
res.status(200).send({ id: "1", title: "Todo" }); // Ok
// Error: Argument of type string is not assignable to parameter of type { id: string; title: string; }
res.status(200).send("Not found");
// Argument of type { id: string; } is not assignable to parameter of type { id: string; title: string; }
res.status(200).json({ id: "1" });
// Error: Argument of type 201 is not assignable to parameter of type 200 | 404
res.status(201);
});
If all the routes in your contract share common responses, you can define them in the common config under the commonResponses
property. If a route response overrides a common response, the route response takes precedence for that route.
const app = zodpress({
commonResponses: {
500: z.literal("Internal error")
},
get: {
"/todos/:id": {
responses: {
200: z.object({ id: z.string(), title: z.string() })
}
}
}
});
app.z.get("/todos/:id", (req, res) => {
res.status(500).send("Internal error"); // Ok
res.status(200).send({ id: "1", title: "Todo" }); // Ok
});
To signal an empty response, use the z.void()
schema. This will automatically infer the response body type as void
and document it as empty when generating OpenAPI documents.
const app = zodpress({
delete: {
"/todos/:id": {
responses: {
204: z.void()
}
}
}
});
When defining either headers, path params, query params, or request body schemas, Zodpress will validate the content of the request against these schemas before calling your route handlers. If validation fails, a ValidationError
is created containing all the errors. What happens next depends on the validationErrorPolicy
option. This option can be set as a common config or a route config. Keep in mind that route configs take precedence over the common config. Here are the possible values:
"send"
: The error is sent back to the client as a JSON response with a 400 status code. This is the default behavior."forward"
: The error is forwarded to the next error handler Express finds (internally it doesnext(error)
). Useful when you have a custom error handler somewhere else in your app."ignore"
: The error is ignored. Useful when debugging.ErrorRequestHandler
: A custom error handler is called. The function will receive the following arguments: error, request, response, next function.
Example:
const app = zodpress({
validationErrorPolicy: "forward",
get: {
"/todos/:id": {
params: z.object({
id: z.string().uuid()
})
}
}
});
app.z.get("/todos/:id", (req, res) => {
// ...
});
app.use((err, req, res, next) => {
if (err instanceof ValidationError) {
res.status(400).json(err);
} else {
next(err);
}
});
By defining your API contract, you can easily generate OpenAPI-compliant documentation without duplicating your source of truth. This means your contract serves as a single, consistent definition for static type-safety, runtime validation, and documentation. Under the hood, Zodpress uses zod-to-openapi to generate OpenAPI documents from your Zod schemas and other contract properties. This process is fully customizable.
The OpenAPI functionalities are accessible via the z.openapi
property on apps and routers. Call z.openapi.generate
to generate an OpenAPI 3.0 document:
const app = zodpress({
get: {
"/todos/:id": {
summary: "Get a todo",
description: "Get a todo by its ID",
responses: {
200: z.object({ id: z.string(), title: z.string() })
}
}
}
});
const document = app.z.openapi.generate({
openapi: "3.0.0",
info: {
title: "My API",
version: "2.0.0"
}
});
This will output the following:
{
"openapi": "3.0.0",
"info": {
"title": "My API",
"version": "2.0.0"
},
"components": {
"schemas": {},
"parameters": {}
},
"paths": {
"/todos/{id}": {
"get": {
"summary": "Get a todo",
"description": "Get a todo by its ID",
"tags": [],
"parameters": [
{
"schema": { "type": "string" },
"required": true,
"name": "id",
"in": "path"
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["id", "title"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" }
}
}
}
}
}
}
}
}
}
}
Contract route configs can take the following metadata used solely for OpenAPI generation:
summary
: Short summary of what the route does.description
: Detailed description of the route.deprecated
: Marks a route as deprecated. Also available on common config to mark all routes in the contract as deprecated.tags
: String or string array. OpenAPI tags for this route. Also available on common config to tag all routes in the contract. If defined both on a route and common config, the tags are merged for the route.
const app = zodpress({
deprecated: true,
tags: ["todos"],
get: {
"/todos/:id": {
summary: "Get a todo",
description: "Get a todo by its ID",
deprecated: false, // Takes precedence
tags: ["read"] // Final tags: ["todos", "read"]
}
}
});
OpenAPI allows for more metadata to be added to routes. While Zodpress offers first-class support for the ones listed above, it is possible to add or customize any OpenAPI operation field using the openapi
property. Anything defined in there will take precedence in case of conflict.
const app = zodpress({
get: {
"/todos/:id": {
openapi: {
security: [{ apiKey: [] }]
}
}
}
});
Zodpress will also use Zod's built-in description (see describe
method) to optionally add descriptions to request and response bodies.
const app = zodpress({
post: {
"/todos": {
body: z.object({ title: z.string() }).describe("The todo to create")
}
}
});
When nesting routers, Zodpress will automatically understand how to prefix the paths of the nested routes:
const app = zodpress({
get: {
"/health": {
summary: "Check the health of the API"
}
}
});
const router = zodpress.Router({
get: {
"/todo": {
summary: "Get a todo"
}
}
});
app.use("/api", router);
const document = app.z.openapi.generate({
openapi: "3.0.0",
info: {
title: "My API",
version: "2.0.0"
}
});
This will generate an OpenAPI document looking like this:
{
...
"paths": {
"/health": {
"get": {
"summary": "Check the health of the API",
...
}
},
"/api/todo": {
"get": {
"summary": "Get a todo",
...
}
}
}
}
However, when attaching a Zodpress router to a regular Express app, Zodpress has no way to know the base path of the router. To solve this, you can pass a second argument to z.openapi.generate
containing a pathPrefix
property.
const app = express();
const router = zodpress.Router({
get: {
"/todo": {
summary: "Get a todo"
}
}
});
app.use("/api", router);
const document = router.z.openapi.generate(
{
openapi: "3.0.0",
info: {
title: "My API",
version: "2.0.0"
}
},
{ pathPrefix: "/api" }
);
Request and response content types can be explicitly specified. To do this, extend Zod with the built-in extension:
import { z } from "zod";
import { extendZodWithZodpress } from "zodpress";
extendZodWithZodpress(z);
You can now use the contentType
method on Zod schemas. This will not affect runtime validation (although it might in the future), but will be used to generate accurate OpenAPI documents.
const app = zodpress({
post: {
"/file-to-text": {
body: z.instanceof(Buffer).contentType("application/octet-stream"),
responses: {
200: z.string().contentType("text/plain")
}
}
}
});
app.use(express.raw());
By extending Zod with @asteasolutions/zod-to-openapi's extension, you can use the openapi
method on Zod schemas to customize their OpenAPI representation. This is useful for adding descriptions, examples or extracting schemas into OpenAPI components to be reused in multiple routes.
import { z } from "zod";
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
extendZodWithOpenApi(z);
const todoSchema = z
.object({
id: z.string(),
title: z.string()
})
.openapi("Todo", {
example: { id: "1", title: "Todo 1" }
});
const app = zodpress({
get: {
"/todos/:id": {
responses: {
200: todoSchema
}
}
}
});
If you want to use both the openapi
and the contentType
methods, you can import z
from zodpress/zod
. All it does is extend Zod with both the zod-to-openapi and Zodpress extensions.
import { z } from "zodpress/zod";
To add custom OpenAPI components like security schemes, pass a second argument to z.openapi.generate
with a with
callback. The callback receives a registry parameter that lets you register components like schemas, parameters, links, security schemes and more.
const app = zodpress({
get: {
"/todo": {
summary: "Get a todo",
openapi: {
security: [{ "x-api-key": [] }]
}
}
}
});
const document = app.z.openapi.generate(
{
openapi: "3.0.0",
info: {
title: "My API",
version: "2.0.0"
}
},
{
with: registry => {
registry.registerComponent("securitySchemes", "x-api-key", {
type: "apiKey",
in: "header",
name: "x-api-key"
});
}
}
);
If you are already using zod-to-openapi outside of Zodpress, you might not want Zodpress to handle the OpenAPI generation directly. In this case, you can use the z.openapi.register
method to register the routes with an existing OpenAPI registry and manually generate the document later.
const app = zodpress({});
const registry = new OpenAPIRegistry();
app.z.openapi.register(registry);
To generate frontend types from your contract, generate the OpenAPI document and use available tools to convert it to TypeScript types. Here is a non-exhaustive list of tools you can use:
- Support for common headers (similar to
commonResponses
) - Support for content-type header validation using
zodSchema.contentType()
metadata [maybe] - Automatically document response code 400 in OpenAPI documents for validated routes
Function | Returns | Description |
---|---|---|
zodpress(contract: Contract) |
Application |
Creates an Express application with Zodpress features. The contract parameter defines the API contract for type-safety and OpenAPI generation. |
zodpress.Router(contract: Contract, options?: RouterOptions) |
Router |
Creates an Express router with Zodpress features. The contract parameter defines the API contract for type-safety and OpenAPI generation. Takes standard Express router options. |
zodpress.contract(contract: Contract) |
Contract |
Helper function to define a contract with proper type inference. Returns the contract as-is. |
Property | Type | Description |
---|---|---|
deprecated? |
boolean |
Mark all routes in the contract as deprecated |
tags? |
string | string[] |
OpenAPI tags for all routes in the contract |
validationErrorPolicy? |
"send" | "forward" | "ignore" | ErrorRequestHandler |
Default validation error handling policy for all routes |
commonResponses? |
{ [status: number]: ZodType } |
Common response schemas shared by all routes |
get? |
{ [path: string]: RouteConfig } |
GET route configurations |
post? |
{ [path: string]: RouteConfig } |
POST route configurations |
put? |
{ [path: string]: RouteConfig } |
PUT route configurations |
patch? |
{ [path: string]: RouteConfig } |
PATCH route configurations |
delete? |
{ [path: string]: RouteConfig } |
DELETE route configurations |
Each route config accepts:
Property | Type | Description |
---|---|---|
summary? |
string |
Short summary of what the route does |
description? |
string |
Detailed description of the route |
deprecated? |
boolean |
Mark this route as deprecated |
tags? |
string | string[] |
OpenAPI tags for this route |
validationErrorPolicy? |
"send" | "forward" | "ignore" | ErrorRequestHandler |
Validation error handling for this route |
openapi? |
Partial<OpenAPIRouteConfig> |
Additional OpenAPI configuration |
headers? |
ZodObject |
Schema for validating request headers |
params? |
ZodObject |
Schema for validating path parameters |
query? |
ZodObject |
Schema for validating query parameters |
body? |
ZodType |
Schema for validating request body |
responses? |
{ [status: number]: ZodType } |
Response schemas by status code |
The z
property is available on Zodpress applications and routers.
Property/Method | Returns | Description |
---|---|---|
contract |
Contract |
The contract bound to this router/app |
openapi |
ZodpressOpenAPI |
The OpenAPI generator for this router/app |
get(path, ...handlers) |
this |
Adds GET route handlers with full type safety based on contract |
post(path, ...handlers) |
this |
Adds POST route handlers with full type safety based on contract |
put(path, ...handlers) |
this |
Adds PUT route handlers with full type safety based on contract |
patch(path, ...handlers) |
this |
Adds PATCH route handlers with full type safety based on contract |
delete(path, ...handlers) |
this |
Adds DELETE route handlers with full type safety based on contract |
The ZodpressOpenAPI
interface provides the following methods:
Method | Returns | Description |
---|---|---|
register(registry: OpenAPIRegistry, options?: OpenAPIRegisterOptions) |
void |
Registers this router/app's routes with a provided OpenAPI registry |
generate(config: OpenAPIDocumentConfig, options?: OpenAPIRegisterOptions) |
OpenAPIDocument |
Generates an OpenAPI 3.0 document. The config parameter accepts standard OpenAPI 3.0 document fields (info , servers , etc.). See zod-to-openapi. |
generateV31(config: OpenAPIDocumentConfigV31, options?: OpenAPIRegisterOptions) |
OpenAPIDocumentV31 |
Generates an OpenAPI 3.1 document |
The OpenAPIRegisterOptions
interface accepts the following properties:
Property | Type | Description |
---|---|---|
pathPrefix? |
string |
Prefix all paths with this string |
with?(registry) |
Function |
Callback to interact with the underlying OpenAPI registry |
The ValidationError
class extends the standard Error
class and is used to represent validation errors.
Method | Returns | Description |
---|---|---|
isEmpty() |
boolean |
Returns true if it doesn't contain any errors |
headersErrors? |
ZodIssue[] |
Array of validation errors for headers |
paramsErrors? |
ZodIssue[] |
Array of validation errors for path params |
queryErrors? |
ZodIssue[] |
Array of validation errors for query params |
bodyErrors? |
ZodIssue[] |
Array of validation errors for request body |
Function | Returns | Description |
---|---|---|
extendZodWithZodpress(zod: typeof z) |
void |
Extends Zod with the contentType() method to specify custom content types for request/response schemas. Only needs to be called once. The contentType() method accepts a string like "text/plain" and returns a new Zod schema. |
Type | Description |
---|---|
RequestHandler<Contract, Method, Path> |
Type for request handlers, inferred from the contract |
RequestHeaders<Contract, Method, Path> |
Type for request headers, inferred from the contract |
RequestParams<Contract, Method, Path> |
Type for request path parameters, inferred from the contract |
RequestQuery<Contract, Method, Path> |
Type for request query parameters, inferred from the contract |
RequestBody<Contract, Method, Path> |
Type for request body, inferred from the contract |
ResponseMap<Contract, Method, Path> |
Type for response map, mapping status codes to response types, inferred from the contract |
ResponseCode<ResponseMap> |
Type for response status codes, inferred from the response map |
ResponseBody<ResponseMap> |
Type for response body, inferred from the response map |
inferContract<Router> |
Utility type to infer the contract from a Zodpress router or app |
inferHandler<Router, Method, Path> |
Utility type to infer the request handler type from a Zodpress router or app |
See CHANGELOG.md for more information.