Express Zod API



Start your API server with I/O schema validation and custom middlewares in minutes.
- Technologies
- Concept
- Installation
- Basic usage
- Set up config
- Create an endpoints factory
- Create your first endpoint
- Set up routing
- Start your server
- Advanced usage
- Create a middleware
- Refinements
- Transformations
- ResultHandler
- Non-object response
- Your custom logger
- Your custom server
- Multiple schemas for a single route
- Disclosing API specifications
- Reusing endpoint types on your frontend
- Swagger / OpenAPI Specification
- Known issues
- Excess property check of endpoint output
- Your input to my output
If you're upgrading from v1 please check out the information in Changelog.
Technologies
Concept
The API operates object schemas for input and output, including unions and intersections of object schemas
(.or()
, .and()
), but in general the API can respond with any data type
(see advanced example below).
The object being validated is the request.query
for GET request, the request.body
for PUT, PATCH and POST requests,
or their merging for DELETE requests.
Middlewares can handle validated inputs and the original request
, for example, to perform the authentication or
provide the endpoint's handler with some request properties like the actual method. The returns of middlewares are
combined into the options
parameter available to the next middlewares and the endpoint's handler.
The handler's parameter input
combines the validated inputs of all connected middlewares along with the handler's one.
The result that the handler returns goes to the ResultHandler
which is responsible for transmission of the final
response or possible error.
All inputs and outputs are validated and there are also advanced powerful features like transformations and refinements.
The diagram below can give you a better idea of the dataflow.

Installation
yarn add express-zod-api
# or
npm install express-zod-api
Add the following option to your tsconfig.json
file in order to make it work as expected:
{
"compilerOptions": {
"strict": true
}
}
Basic usage
See the full implementation example here.
Set up config
import {ConfigType} from 'express-zod-api';
const config: ConfigType = {
server: {
listen: 8090,
},
cors: true,
logger: {
level: 'debug',
color: true
}
};
See all available options here.
Create an endpoints factory
import {defaultEndpointsFactory} from './endpoints-factory';
const endpointsFactory = defaultEndpointsFactory;
You can also instantly add middlewares to it using .addMiddleware()
method.
Create your first endpoint
import {z} from 'express-zod-api';
const setUserEndpoint = endpointsFactory.build({
method: 'post',
input: z.object({
id: z.number(),
name: z.string(),
}),
output: z.object({
timestamp: z.number(),
}),
handler: async ({input: {id, name}, options, logger}) => {
logger.debug(`Requested id: ${id}`);
logger.debug('Options:', options);
return { timestamp: Date.now() };
}
});
The endpoint can also handle multiple types of requests, this feature is available by using methods
property that
accepts an array. You can also add middlewares to the endpoint by using .addMiddleware()
before .build()
.
Set up routing
import {Routing} from 'express-zod-api';
const routing: Routing = {
v1: {
setUser: setUserEndpoint
}
};
This implementation sets up setUserEndpoint
to handle requests to the /v1/setUser
route.
Start your server
import {createServer} from 'express-zod-api';
createServer(config, routing);
Advanced usage
Create a middleware
You can create middlewares separately using createMiddleware()
function and connect them later.
All returns of the connected middlewares are combined into the options
argument of the endpoint's handler.
The inputs of middlewares are combined with the inputs of the endpoint's handler.
import {
createMiddleware, z, Method, createHttpError
} from 'express-zod-api';
const methodProviderMiddleware = createMiddleware({
input: z.object({}).nonstrict(),
middleware: async ({request}) => ({
method: request.method.toLowerCase() as Method,
})
});
const authMiddleware = createMiddleware({
input: z.object({
key: z.string().nonempty()
}),
middleware: async ({input: {key}, request, logger}) => {
logger.debug('Checking the key and token...');
const user = await db.Users.findOne({key});
if (!user) {
throw createHttpError(401, 'Invalid key');
}
if (request.headers['token'] !== user.token) {
throw createHttpError(401, 'Invalid token');
}
return { user };
}
});
Refinements
You can implement additional validation inside the schema:
import {createMiddleware, z} from 'express-zod-api';
const authMiddleware = createMiddleware({
input: z.object({
key: z.string().nonempty()
.refine((key) => key === '123', 'Invalid key')
}),
...
})
Transformations
Since parameters of GET requests come in the form of strings, there is often a need to transform them into numbers or
arrays of numbers.
import {z} from 'express-zod-api';
const getUserEndpoint = endpointsFactory.build({
method: 'get',
input: z.object({
id: z.string().transform((id) => parseInt(id, 10)),
ids: z.string().transform(
(ids) => ids.split(',').map((id) => parseInt(id, 10))
)
}),
output: z.object({...}),
handler: async ({input: {id, ids}, logger}) => {
logger.debug('id', id);
logger.debug('ids', ids);
}
});
ResultHandler
ResultHandler
is the type of
function that is responsible for transmission of the final response or possible error.
ResultHandlerDefinition
contains this handler and additional methods defining the schema of the positive and
negative responses as well as their MIME types for the further disclosing to consumers and documentation.
Positive schema is the schema of successful response. Negative schema is the one that describes the response in case
of error. The defaultResultHandler
sets the HTTP status code and ensures the following type of the response:
type DefaultResponse<OUT> = {
status: 'success',
data: OUT
} | {
status: 'error',
error: {
message: string;
}
};
In order to customize the result handler you need to use createResultHandler()
first wrapping the response schema in
createApiResponse()
optionally specifying its mime types, and wrapping the endpoint output schema in markOutput()
.
Here is an example you can use as a template:
import {createResultHandler, IOSchema, createApiResponse, markOutput, z} from 'express-zod-api';
const myResultHandler = createResultHandler({
getPositiveResponse: <OUT extends IOSchema>(output: OUT) => createApiResponse(
z.object({
...,
someProperty: markOutput(output)
}),
['mime/type1', 'mime/type2']
),
getNegativeResponse: () => createApiResponse(z.object({
error: z.string()
})),
handler: ({error, input, output, request, response, logger}) => {
}
});
Then you need to use it as an argument for EndpointsFactory
instance creation:
import {EndpointsFactory} from 'express-zod-api';
const endpointsFactory = new EndpointsFactory(myResultHandler);
Non-object response
Starting from the version 2.0.0, ResultHandler
also supports non-object response types, for example, sending an
image file including its MIME type in Content-type
header.
You can find two approaches to EndpointsFactory
and ResultHandler
implementation
in this example.
One of them implements file streaming, in this case the endpoint just has to provide the filename.
The response schema generally may be just z.string()
, but there is also a specific one: z.file()
that also supports
.binary()
and .base64()
refinements which are reflected in the
generated documentation.
const fileStreamingEndpointsFactory = new EndpointsFactory(createResultHandler({
getPositiveResponse: () => createApiResponse(z.file().binary(), 'image/*'),
getNegativeResponse: () => createApiResponse(z.string(), 'text/plain'),
handler: ({response, error, output}) => {
if (error) {
response.status(400).send(error.message);
return;
}
if ('filename' in output) {
fs.createReadStream(output.filename)
.pipe(response.type(output.filename));
} else {
response.status(400).send('Filename is missing');
}
}
}));
Your custom logger
You can specify your custom Winston logger in config:
import * as winston from 'winston';
import {ConfigType, createServer} from 'express-zod-api';
const config: ConfigType = {
logger: winston.createLogger(),
...
};
createServer(config, routing);
Your custom server
You can instantiate your own express app and connect your endpoints the following way.
import * as express from 'express';
import {ConfigType, attachRouting} from 'express-zod-api';
const app = express();
const config: ConfigType = {app, ...};
const routing = {...};
attachRouting(config, routing);
app.listen();
Please note that in this case you probably need to: parse request.body
, call app.listen()
and handle 404
errors yourself;
Multiple schemas for a single route
Thanks to the DependsOnMethod
class a route may have multiple Endpoints attached depending on different methods.
It can also be the same Endpoint that handles multiple methods as well.
import {DependsOnMethod} from 'express-zod-api';
const routing: Routing = {
v1: {
user: new DependsOnMethod({
get: myEndpointForGetAndDelete,
delete: myEndpointForGetAndDelete,
post: myEndpointForPostAndPatch,
patch: myEndpointForPostAndPatch,
})
}
};
Disclosing API specifications
Reusing endpoint types on your frontend
You can export only the types of your endpoints for your front-end:
export type MyEndpointType = typeof endpoint;
Then use provided helpers to obtain their input and response types:
import {EndpointInput, EndpointResponse} from 'express-zod-api';
import {MyEndpointType} from '../your/backend';
type MyEndpointInput = EndpointInput<MyEndpointType>;
type MyEndpointResponse = EndpointResponse<MyEndpointType>;
Swagger / OpenAPI Specification
You can generate the specification of your API the following way and write it to a .yaml
file,
that can be used as documentation:
import {OpenAPI} from 'express-zod-api';
const yamlString = new OpenAPI({
routing,
version: '1.2.3',
title: 'Example API',
serverUrl: 'http://example.com'
}).getSpecAsYaml();
Known issues
Excess property check of endpoint output
Unfortunately Typescript does not perform
excess property check for
objects resolved in Promise
, so there is no error during development of endpoint's output.
import {z} from 'express-zod-api';
endpointsFactory.build({
methods, input,
output: z.object({
anything: z.number()
}),
handler: async () => ({
anything: 123,
excessive: 'something'
})
});
You can achieve this check by assigning the output schema to a constant and reusing it in additional definition of
handler's return type:
import {z} from 'express-zod-api';
const output = z.object({
anything: z.number()
});
endpointsFactory.build({
methods, input, output,
handler: async (): Promise<z.input<typeof output>> => ({
anything: 123,
excessive: 'something'
})
});
Your input to my output
Do you have a question or idea?
Your feedback is highly appreciated in Discussions section.
Found a bug?
Please let me know in Issues section.
Found a vulnerability or other security issue?
Please refer to Security policy.