
Validation with Express.js middleware, fp-ts and Zod
This post is about setting up Express.js middleware to validate data in the request body, URL parameters, or query parameters using fp-ts and Zod.
There are multiple ways to approach this, and this is one of them.
Let’s start by writing a utility function:
// ./src/utils.ts
import { either as E } from "fp-ts";
import { z } from "zod";
export const parseZ =
<T>(zodType: z.ZodType<T>) =>
(v: unknown): E.Either<z.ZodError<T>, T> => {
const result = zodType.safeParse(v);
switch (result.success) {
case true:
return E.right(result.data);
case false:
return E.left(result.error);
}
};
We’ll be using fp-ts today but we’ll keep it at a minimum and mainly use the Either monad.
What is the Either monad?
In fp-ts, the Either
type is a monad that can be used to handle errors in a functional and composable way. Computation that succeeds are represented by a value wrapped in a Right
constructor, while computations that fail are represented by a value wrapped in a Left
constructor.
So the above parseZ
acts as a wrapper around Zod’s safeParse
function. It returns a Right
that contains the data if parsing was successful and a Left
containing a ZodError if it failed.
We will now use this in our first middleware:
// ./src/types.ts
export type RequestContentLocation = "body" | "params" | "query";
// ./src/middleware.ts
import { RequestHandler } from "express";
import { function as F, either as E } from "fp-ts";
import { parseZ } from "./utils";
import { RequestValidationError } from "./errors";
import { RequestContentLocation } from "./types";
export const validateRequest =
<T>({
requestContentLocation,
zodType,
}: {
requestContentLocation: RequestContentLocation;
zodType: z.ZodType<T>;
}): RequestHandler =>
(req, _, next) => {
const requestContentParser = parseZ(zodType);
const requestContent = req[requestContentLocation];
return F.pipe(
requestContentParser(requestContent),
E.fold(
(err) => next(new RequestValidationError(requestContentLocation, err)),
() => next()
)
);
};
The validateRequest
middleware takes in a requestContentLocation
parameter which we use to specify where the Zod parser should be run on. We can choose to validate data in the request body, URL parameters, or query parameters.
The result of requestContentParser
function is then passed into the fold function available in the Either
monad to pass that result into the next middleware using the next
function.
What is the fold
function?
The fold
method is a way to extract values from an Either
instance in a safe and composable way. It takes two functions as arguments, one for handling the Left
case and one for handling the Right
case. The fold
method applies the appropriate function based on whether the Either value is a Left
or Right
, and returns the result of that function.
In this instance, if requestContentParser
returns a Left<ZodError>
, we pass the error into next. Express will then handle that error for us.
Otherwise, if the parsing was successful, we call the next
function without an argument.
Here’s the custom error class we will use to wrap the original ZodError
returned by the parseZ
function. We will also pass the requestContentLocation
when initialising this class as it will be helpful down the line. More on this later.
// ./src/errors.ts
import { ZodError } from "zod";
import { RequestContentLocation } from "./types";
export class RequestValidationError extends Error {
requestContentLocation: RequestContentLocation;
zodError: ZodError;
constructor(
requestContentLocation: RequestContentLocation,
zodError: ZodError
) {
super("Request validation error");
this.requestContentLocation = requestContentLocation;
this.zodError = zodError;
}
}
In the following section of the code, we will create three additional functions - validateRequestBody
, validateRequestQueries
, and validateRequestParams
- that wrap around the validateRequest
middleware:
// ./src/middleware.ts
import { ParsedQs } from "qs";
import { z } from "zod";
import { ErrorRequestHandler, RequestHandler } from "express";
import { ParamsDictionary } from "express-serve-static-core";
export const validateRequestBody = <T>(
zodType: z.ZodType<T>
): RequestHandler<any, any, T, any, any> =>
validateRequest({ requestContentLocation: "body", zodType });
export const validateRequestQueries = <T extends ParsedQs>(
zodType: z.ZodType<T>
): RequestHandler<any, any, any, T, any> =>
validateRequest({ requestContentLocation: "query", zodType });
export const validateRequestParams = <T extends ParamsDictionary>(
zodType: z.ZodType<T>
): RequestHandler<T, any, any, any, any> =>
validateRequest({ requestContentLocation: "params", zodType });
These functions are a way to abstract away the implementation details of the validateRequest
middleware, making it easier to use and ensuring that the types of the request parameters are correctly inferred.
Now let’s create an endpoint to test all these out:
// ./src/types.ts
import { z } from "zod";
export type APIResponseBodySuccessful<T = null> = {
success: true;
result: T;
error: null;
};
export const ExampleRequestQueriesZ = z.object({ foo: z.string().optional() });
export type ExampleRequestQueries = z.infer<typeof ExampleRequestQueriesZ>;
export const ExampleRequestBodyZ = z.object({ bar: z.string().optional() });
export type ExampleRequestBody = z.infer<typeof ExampleRequestBodyZ>;
export const ExampleRequestParamsZ = z.object({ baz: z.string().optional() });
export type ExampleRequestParams = z.infer<typeof ExampleRequestParamsZ>;
export type ExampleAPIResponseBody = APIResponseBodySuccessful<{
queries: ExampleRequestQueries;
body: ExampleRequestBody;
params: ExampleRequestParams;
}>;
// ./src/controllers.ts
import { RequestHandler } from "express";
import {
ExampleAPIResponseBody,
ExampleRequestBody,
ExampleRequestQueries,
ExampleRequestParams,
} from "./types";
export const exampleController: RequestHandler<
ExampleRequestParams,
ExampleAPIResponseBody,
ExampleRequestBody,
ExampleRequestQueries
> = (req, res) => {
res.send({
success: true,
result: {
queries: req.query,
body: req.body,
params: req.params
},
error: null,
});
};
// ./src/routes.ts
import { Router } from "express";
import bodyParser from "body-parser";
import { exampleController } from "./controllers";
import {
ExampleRequestBodyZ,
ExampleRequestQueriesZ,
ExampleRequestParamsZ,
} from "./types";
import {
validateRequestBody,
validateRequestQueries,
validateRequestParams,
} from "./middleware";
export const router = Router();
export const validateExampleRequestQueriesFn = validateRequestQueries(
ExampleRequestQueriesZ
);
export const validateExampleRequestBodyFn =
validateRequestBody(ExampleRequestBodyZ);
export const validateExampleRequestParamsFn = validateRequestParams(
ExampleRequestParamsZ
);
router.post(
"/:baz?",
bodyParser.json(),
validateExampleRequestQueriesFn,
validateExampleRequestBodyFn,
validateExampleRequestParamsFn,
exampleController
);
export default router;
Now let’s call the endpoint:
curl -X POST -H "Content-Type: application/json" -d '{"bar": "bar"}' "http://localhost:3000/baz?foo=foo" | jq
...
{
"success": true,
"result": {
"queries": {
"foo": "foo"
},
"body": {
"bar": "bar"
},
"params": {
"baz": "baz"
}
},
"error": null
}
As you can see, we received a nicely formatted JSON response.
But what if we force a validation error?
curl -X POST -H "Content-Type: application/json" -d '{"bar": 1}' "http://localhost:3000/baz?foo=foo"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: Request validation error<br> ...</pre>
</body>
</html>
We received a stack trace as HTML. Unfortunately this is not very useful if we are planning to use this route as a REST API endpoint that takes in inputs and returns outputs purely in JSON.
How do we fix this? With another middleware:
// ./src/types.ts
export type APIResponseBodyFailure<T> = {
success: false;
result: null;
error: T;
};
// ./src/middleware.ts
import { ErrorRequestHandler } from "express";
export const transformToAPIFailureResponse: ErrorRequestHandler<
any,
APIResponseBodyFailure<unknown>
> = (err, _, res, next) => {
if (err instanceof RequestValidationError) {
const { requestContentLocation: location, zodError } = err;
return res.status(400).json({
success: false,
result: null,
error: {
location,
zodErrors: zodError.errors,
},
});
}
// Handle other errors here.
return next(err);
};
The transformToAPIFailureResponse
middleware will be “invoked” whenever the next
function is called with an error. In this case that’s done by the validateRequest
middleware.
This middleware will look at the err
parameter and matches it against a list of Error
classes that we know of. Here we are doing that against the previously created RequestValidationError
error. Then, thanks to instanceof
and type guards, we are able to extract properties from the error and map them to a JSON response and send it down with the correct status code.
It is important to register this middleware after your routes to get it working:
// ./src/index.ts
import express from "express";
import routes from "./routes";
import { transformToAPIFailureResponse } from "./middleware";
const app = express();
const port = 3000;
app.use(routes);
app.use(transformToAPIFailureResponse);
Now let’s see how the error response look like:
curl -X POST -H "Content-Type: application/json" -d '{"bar": 1}' "http://localhost:3000/baz?foo=foo" | jq
...
{
"success": false,
"result": null,
"error": {
"location": "body",
"zodErrors": [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": [
"bar"
],
"message": "Expected string, received number"
}
]
}
}
That’s it!
I believe this approach is highly extensible. Meaning it’s very easy handle future error cases, especially when there’s multiple endpoints to maintain.
The code for this is available on github.com/chehanr/express-boilerplate.