DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Setup express with Typescript - request body validation with zod

Introduction

In this series we will setup an express server using Typescript, we will be using TypeOrm as our ORM for querying a PostgresSql Database, we will also use Jest and SuperTest for testing. The goal of this series is not to create a full-fledged node backend but to setup an express starter project using typescript which can be used as a starting point if you want to develop a node backend using express and typescript.

Overview

This series is not recommended for beginners some familiarity and experience working with nodejs, express, typescript and typeorm is expected. In this post which is part six of our series we will : -

  • Add zod validations for todos.
  • Implement zod-express-middleware on our own.
  • Add the validation middleware to our routes.

Step One: Zod validations

Lets first install zod in our project by running -

npm install --save zod
Enter fullscreen mode Exit fullscreen mode

Then under src/api/todos create a new file todos.validation.ts and paste the following code :

import { z } from 'zod';

import { TodoStatus } from './todos.entity';

export const todosSchema = z.object({
  text: z.string({
    invalid_type_error: 'todo text should be a string',
    required_error: 'todo text is required',
  }),
  status: z.enum([TodoStatus.PENDING, TodoStatus.DONE], {
    errorMap() {
      return {
        message: 'todo status must be pending | done',
      };
    },
  }),
});

export const todoGetSchema = z.object({
  todoId: z
    .string({
      required_error: 'todoId is required',
    })
    .uuid({
      message: 'todo id should be uuid',
    }),
});
Enter fullscreen mode Exit fullscreen mode

The above code is self-explanatory, if you are new to zod, please check their github page, there you can find the docs related to each method we used above.

Now lets create a new Error class, under src/utils create a file ValidationError.ts and paste the following -

import { BaseError } from './BaseError';

export class ValidationError extends BaseError {
  status: number;
  validationErrors: unknown;

  constructor(message: string, status: number, validationErrors: unknown) {
    super(message);
    this.status = status;
    this.validationErrors = validationErrors;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now lets handle our validation errors in the globalErrorHandler function in the server.ts -

 private globalErrorHandler() {
    this.app.use(
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      (error: Error, req: Request, res: Response, next: NextFunction) => {
        console.log('Error (Global Error Handler)', error.stack);
        // Handle 404 not found routes
        if (error instanceof NotFoundError) {
          return res.status(error.status).json({
            status: false,
            statusCode: error.status,
            message: error.message,
          });
        }

       // Handle zod request body, params validation
        if (error instanceof ValidationError) {
          return res.status(error.status).json({
            status: false,
            statusCode: error.status,
            message: error.message,
            errors: error.validationErrors,
          });
        }

        // Handling internal server errors
        return res.status(500).json({
          status: false,
          statusCode: 500,
          message: 'Something unusual Happened',
        });
      }
    );
  }
Enter fullscreen mode Exit fullscreen mode

Step Two: Setup zod validation middleware

zod-express-middleware is an awesome library for a validation middleware. The only issue is this library sends responses directly from the middleware, I want to handle all the errors in the globalErrorHandler instead. To do so we need to throw our errors from the middleware. So what do we do ? Well we read the code of this library form github and implement it on our own. Now this section is a bit complex bear with me, you need to read and re-read the code to understand what we are doing if you are not too familiar with TypeScript generics and reading types of external libraries.

Under src/middlewares create a new file validate.ts -

import { Request, RequestHandler } from 'express';
import { ParamsDictionary } from 'express-serve-static-core';
import { ZodSchema, ZodTypeDef, ZodType, z } from 'zod';

import { ValidationError } from '../utils/ValidationError';

export const validateRequestBody: <TBody>(
  zodSchema: ZodSchema<TBody>
) => RequestHandler<ParamsDictionary, any, TBody, any> =
  (schema) => (req, res, next) => {
    const parsed = schema.safeParse(req.body);
    if (parsed.success) {
      return next();
    } else {
      const { fieldErrors } = parsed.error.flatten();
      const error = new ValidationError('error validating request body', 422, [
        { type: 'Body', errors: fieldErrors },
      ]);
      throw error;
    }
 };
Enter fullscreen mode Exit fullscreen mode
  • We created a new function validateRequestBody which will validate our request body, just forget the typescript types. This function is a curried function -
const validateRequestBody = (schema) => (req, res, next) => {}
Enter fullscreen mode Exit fullscreen mode
  • In the function we are validating our req.body with the zod schema passed. If parsing results in error we are throwing a Validation error which will be handled in the globalErrorHandler.

Similarly we will create 2 functions one to validate request params and other to validate request query params -

export const validateRequestParams: <TParams>(
  zodSchema: ZodSchema<TParams>
) => RequestHandler<TParams, any, any, any> = (schema) => (req, res, next) => {
  const parsed = schema.safeParse(req.params);
  if (parsed.success) {
    return next();
  } else {
    const { fieldErrors } = parsed.error.flatten();
    const error = new ValidationError('error validating request params', 422, [
      { type: 'Params', errors: fieldErrors },
    ]);
    throw error;
  }
};

export const validateRequestQuery: <TQuery>(
  zodSchema: ZodSchema<TQuery>
) => RequestHandler<ParamsDictionary, any, any, TQuery> =
  (schema) => (req, res, next) => {
    const parsed = schema.safeParse(req.query);
    if (parsed.success) {
      return next();
    } else {
      const { fieldErrors } = parsed.error.flatten();
      const error = new ValidationError(
        'error validating request query params',
        422,
        [{ type: 'Query', errors: fieldErrors }]
      );
      throw error;
    }
  };
Enter fullscreen mode Exit fullscreen mode

Now lets understand RequestHandler type in vscode hover over the type you will se the following -

interface RequestHandler<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = qs.ParsedQs, Locals extends Record<string, any> = Record<string, any>>
Enter fullscreen mode Exit fullscreen mode

It accepts 5 parameters, the first four matter to us: -

  • first is the params P
  • second response body ResBody which is any by default
  • third request body ReqBody again any by default
  • fourth request query params ReqQuery

In all our above validation functions we use Generics like TBody, TQuery notice we are passing them right where RequestHandler expects them.
So for the validateRequestBody which validates the body ReqBody, TBody is passed as third param to RequestHandler.
Similarly for validateRequestParams which validates the request params P, TParams is passed as the first param to RequestHandler.

Whats the benefit ? The Benefit of this is you will get typed request body, params if you use our middleware like so -

this.router.get('/', validateRequestBody(todosSchema), (req, res) => {})
Enter fullscreen mode Exit fullscreen mode

But you may ask we won't benefit from this right because we are using controller functions and that too wrapped inside our asyncHandler like -

this.router.post(
  '/',
   validateRequestBody(todosSchema),
   asyncHandler(todosController.createTodo)
);
Enter fullscreen mode Exit fullscreen mode

Well for this express-zod-middleware has some extra types to type your request body, params and query params inside src/utils/validate.ts file paste the following -

export type TypedRequestBody<TBody extends ZodType<any, ZodTypeDef, any>> =
  Request<ParamsDictionary, any, z.infer<TBody>, any>;

export type TypedRequestParams<TParams extends ZodType<any, ZodTypeDef, any>> =
  Request<z.infer<TParams>, any, any, any>;

export type TypedRequestQuery<TQuery extends ZodType<any, ZodTypeDef, any>> =
  Request<ParamsDictionary, any, any, z.infer<TQuery>>;
Enter fullscreen mode Exit fullscreen mode

Lets dissect the above types -

  • First we will use the above types like so -
(req: TypedRequestBody<typeof todosSchema>, res: Response)
Enter fullscreen mode Exit fullscreen mode
  • To TypedRequestBody we pass typeof todosSchema now this evaluates to ZodType from zod. In Vscode hover over ZodType it is a generic with 3 arguments first and third are default any, and second one accepts ZodType. That is how we have typed it here -
<TBody extends ZodType<any, ZodTypeDef, any>>
Enter fullscreen mode Exit fullscreen mode
  • Why are we using ZodType because we will be using z.infer<ZodType> which accepts ZodType therefore our TBody should be of type / should extend ZodType.
  • Now as you have seen previously the Request and RequestHandler types in express receive 5 arguments we are interesed in first 4 arguments where first is params, second is response (default any), third is request (default any) and fourth is query params.
  • In the above code we have used z.infer<> in the right places. To type our request body we used it in the third place, for query params we used it in the fourth place.
  • So the TypedRequestBody should return a type of express Request where the request body is typed according to our zodSchema like so -
Request<ParamsDictionary, any, z.infer<TBody>, any>;
Enter fullscreen mode Exit fullscreen mode

Now we can use the above types in our controllers like so -

async createTodo(req: TypedRequestBody<typeof todosSchema>, res: Response) {
 // our request body will be typed in accordance with todosSchema
  const { text, status } = req.body;
}
Enter fullscreen mode Exit fullscreen mode

express-zod-middleware also has one handy function validateRequest where you can pass 3 schemas one for request body, one for request params and last for request params query and it will validate all of them -

export type RequestValidation<TParams, TQuery, TBody> = {
  params?: ZodSchema<TParams>;
  query?: ZodSchema<TQuery>;
  body?: ZodSchema<TBody>;
};

export const validateRequest: <TParams = any, TQuery = any, TBody = any>(
  schemas: RequestValidation<TParams, TQuery, TBody>
) => RequestHandler<TParams, any, TBody, TQuery> =
  ({ params, query, body }) =>
  (req, res, next) => {
    const errors = [];
    if (params) {
      const parsed = params.safeParse(req.params);
      if (!parsed.success) {
        errors.push({
          type: 'Params',
          errors: parsed.error.flatten().fieldErrors,
        });
      }
    }
    if (query) {
      const parsed = query.safeParse(req.query);
      if (!parsed.success) {
        errors.push({
          type: 'Query',
          errors: parsed.error.flatten().fieldErrors,
        });
      }
    }
    if (body) {
      const parsed = body.safeParse(req.body);
      if (!parsed.success) {
        errors.push({
          type: 'Body',
          errors: parsed.error.flatten().fieldErrors,
        });
      }
    }
    if (errors.length > 0) {
      const error = new ValidationError(
        'error validating request',
        422,
        errors
      );
      throw error;
    }
    return next();
  };
Enter fullscreen mode Exit fullscreen mode

Step 3: Use the request validation middlewares

Lets now use our validation middlewares, in todos.router.ts -

import { Router } from 'express';

import { asyncHandler } from '../../middlewares/asyncHandler';
import {
  validateRequestBody,
  validateRequestParams,
} from '../../middlewares/validate';
import { BaseRouter } from '../../utils/BaseRouter';
import { todosController } from './todos.controller';
import { todosSchema, todoGetSchema } from './todos.validation';

class TodosRouter extends BaseRouter {
  constructor() {
    super();
  }

  addRoutes(): void {
    this.router.get('/', asyncHandler(todosController.getAllTodos));
    this.router.post(
      '/',
      validateRequestBody(todosSchema),
      asyncHandler(todosController.createTodo)
    );
    this.router.get(
      '/:todoId',
      validateRequestParams(todoGetSchema),
      asyncHandler(todosController.getTodoById)
    );
  }

  returnApiEndpointRouter(): Router {
    this.addRoutes();
    return this.router;
  }
}

export const todosRouter = new TodosRouter().returnApiEndpointRouter();
Enter fullscreen mode Exit fullscreen mode

And finally lets type our requests in the controller function -

import { Request, Response } from 'express';

import {
  TypedRequestBody,
  TypedRequestParams,
} from '../../middlewares/validate';
import { todosService } from './todos.service';
import { todosSchema, todoGetSchema } from './todos.validation';

class TodoController {
  async getAllTodos(req: Request, res: Response) {
    const todos = await todosService.getAllTodos();

    return res.status(200).json({
      status: true,
      statusCode: 200,
      todos,
    });
  }

  async getTodoById(
    req: TypedRequestParams<typeof todoGetSchema>,
    res: Response
  ) {
    const { todoId } = req.params;
    const todo = await todosService.getTodoById(todoId);
    if (!todo) {
      return res.status(404).json({
        status: false,
        statusCode: 404,
        message: `todo not found for id - ${todoId}`,
      });
    }

    return res.status(200).json({
      status: true,
      statusCode: 200,
      todo,
    });
  }

  async createTodo(req: TypedRequestBody<typeof todosSchema>, res: Response) {
    const { text, status } = req.body;
    const newTodo = await todosService.createTodo(text, status);

    return res.status(201).json({
      status: true,
      statusCode: 201,
      todo: newTodo.raw,
    });
  }
}

export const todosController = new TodoController();
Enter fullscreen mode Exit fullscreen mode

And with that we come to an end of this tutorial, I agree it was intense. I also feel that many of you might not understand the typescript types for our validations, please feel free to ask questions. If I wanted I could have pulled express-zod-middleware directly but reading open source I learn a lot and I hope you did so.

Overview

All the code for this tutorial can be found under the feat/request-validation branch here. In the next tutorial we will setup basic testing using jest and supertest until next time PEACE.

Top comments (0)