DEV Community

Cover image for Nest JS Websockets - Pipes and E2E Validation with Zod
Delightful Engineering
Delightful Engineering

Posted on • Originally published at delightfulengineering.com

Nest JS Websockets - Pipes and E2E Validation with Zod

Welcome to Part 3: Pipes and E2E Validation with Zod, of building a realtime chat application with Nest JS and React.

If you haven’t checkout out previous parts of this series, go back to follow along.

  • Part 1: Basics - laying the foundations of the application and implementing the most basic websockets features.
  • Part 2: Rooms - implementing websocket rooms and major UI improvements.

This series will attempt to touch on as many features of Nest's websockets integration as well as socket.io in general.

The entire series will be building on one repository that has both the server and the client for the application, so for each part you can download the repository from specific commits to follow along or you can clone the entire repository in it's finished state.

Download the repository at this commit to follow along.

From part 2 of the series we’ve got the following features in our application now:

  • Users can create, join, and leave rooms.
  • Users can send messages in rooms.
  • Users can view other users currently in the room.

End to End Validation

While we have good types that our client and server share, we can do even better.

Not only do we want good types, we also want the ability to validate that our data is correct during runtime for both the frontend and backend.

Here’s what we can do:

  • Define schemas for our data with zod and use schema methods such as parse to do validation checks.
  • Use those schemas to infer types rather than explicitly writing them.
  • Create a custom pipe to do validation on the server with our schemas.
  • Use react-hook-form to enhance our login form and give us the ability to use our schemas to validate on form submissions.

Shared Schemas & Interfaces

Let’s start by looking at some updates to our src/shared directory.

We’ve added a new schemas directory and a new chat.schema.ts file. Now the structure looks like this:

  • interfaces
    • chat.interface.ts
  • schemas
    • chat.schema.ts

With zod, we’re able to create a schema for each field in our object schemas. We can put constraints around these fields and customize a message if these constraints fail when parsed at runtime.

shared/schemas/chat.schema.ts

import { z } from 'zod'

export const UserIdSchema = z.string().min(1).max(24)

export const UserNameSchema = z
  .string()
  .min(1, { message: 'Must be at least 1 character.' })
  .max(16, { message: 'Must be at most 16 characters.' })

export const MessageSchema = z
  .string()
  .min(1, { message: 'Must be at least 1 character.' })
  .max(1000, { message: 'Must be at most 1000 characters.' })

export const TimeSentSchema = z.string()

export const RoomNameSchemaRegex = new RegExp('^\\S+\\w$')

export const RoomNameSchema = z
  .string()
  .min(2, { message: 'Must be at least 2 characters.' })
  .max(16, { message: 'Must be at most 16 characters.' })
  .regex(RoomNameSchemaRegex, {
    message: 'Must not contain spaces or special characters.',
  })

export const SocketIdSchema = z.string().length(20, { message: 'Must be 20 characters.' })

export const UserSchema = z.object({
  userId: UserIdSchema,
  userName: UserNameSchema,
  socketId: SocketIdSchema,
})

export const ChatMessageSchema = z.object({
  user: UserSchema,
  timeSent: TimeSentSchema,
  message: MessageSchema,
  roomName: RoomNameSchema,
})

export const RoomSchema = z.object({
  name: RoomNameSchema,
  host: UserSchema,
  users: UserSchema.array(),
})

export const JoinRoomSchema = z.object({
  user: UserSchema,
  roomName: RoomNameSchema,
})
Enter fullscreen mode Exit fullscreen mode

Now we’ve made some changes to our shared interfaces given that we now have schema for our data. We can use z.infer to infer the types from our schemas. This is AWESOME because now we can write our schemas once, infer types from them, and use them in both the frontend and backend.

shared/interfaces/chat.interface.ts

import { z } from 'zod'
import { ChatMessageSchema, JoinRoomSchema, RoomSchema, UserSchema } from '../schemas/chat.schema'

export type User = z.infer<typeof UserSchema>

export type Room = z.infer<typeof RoomSchema>

export type Message = z.infer<typeof ChatMessageSchema>

export type JoinRoom = z.infer<typeof JoinRoomSchema>

export interface ServerToClientEvents {
  chat: (e: Message) => void
}

export interface ClientToServerEvents {
  chat: (e: Message) => void
  join_room: (e: JoinRoom) => void
}
Enter fullscreen mode Exit fullscreen mode

Server Validation

Let's start by looking at the backend first - our server layer.

Zod Object Schema Validation Pipe

Nest JS has a powerful and simple way to evaluate input data called pipes.

Here is the pipe that we’ve created. Our ZodValidationPipe accepts a constructor arg which is a zod schema. We then parse the schema which will either throw and error resulting in a WsException (websocket exception) if the data does not pass the schema constraints, otherwise the value passes through successfully.

server/pipes/zod.pipe.ts

import { PipeTransform, Injectable } from '@nestjs/common'
import { Schema } from 'zod'

@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: Schema) {}

  transform(value: any) {
    this.schema.parse(value)
    return value
  }
}
Enter fullscreen mode Exit fullscreen mode

With our validation pipe and our schemas, we can now apply this to our websocket gateway.

server/chat/chat.gateway.ts

import {
  MessageBody,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets'
import { Logger, UsePipes } from '@nestjs/common'
import {
  ServerToClientEvents,
  ClientToServerEvents,
  Message,
  JoinRoom,
} from '../../shared/interfaces/chat.interface'
import { Server, Socket } from 'socket.io'
import { UserService } from '../user/user.service'
import { ZodValidationPipe } from '../pipes/zod.pipe'
import { ChatMessageSchema, JoinRoomSchema } from '../../shared/schemas/chat.schema'

@WebSocketGateway({
  cors: {
    origin: '*',
  },
})
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(private userService: UserService) {}

  @WebSocketServer() server: Server = new Server<ServerToClientEvents, ClientToServerEvents>()

  private logger = new Logger('ChatGateway')

  @UsePipes(new ZodValidationPipe(ChatMessageSchema))
  @SubscribeMessage('chat')
  async handleChatEvent(
    @MessageBody()
    payload: Message
  ): Promise<void> {
    this.logger.log(payload)
    this.server.to(payload.roomName).emit('chat', payload) // broadcast messages
  }

  @UsePipes(new ZodValidationPipe(JoinRoomSchema))
  @SubscribeMessage('join_room')
  async handleSetClientDataEvent(
    @MessageBody()
    payload: JoinRoom
  ): Promise<void> {
    if (payload.user.socketId) {
      this.logger.log(`${payload.user.socketId} is joining ${payload.roomName}`)
      await this.server.in(payload.user.socketId).socketsJoin(payload.roomName)
      await this.userService.addUserToRoom(payload.roomName, payload.user)
    }
  }

  async handleConnection(socket: Socket): Promise<void> {
    this.logger.log(`Socket connected: ${socket.id}`)
  }

  async handleDisconnect(socket: Socket): Promise<void> {
    await this.userService.removeUserFromAllRooms(socket.id)
    this.logger.log(`Socket disconnected: ${socket.id}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

Let’s drill in.

We’re able to use the built in @UsePipes decorator to instantiate our new pipe and pass in the ChatMessageSchema.

If the incoming message from a client does not pass the ChatMessageSchema constraints, and error will be throw and the message will not get emitted. This will protect our server from processing bad messages.

@UsePipes(new ZodValidationPipe(ChatMessageSchema))
@SubscribeMessage('chat')
async handleChatEvent(
  @MessageBody()
  payload: Message,
): Promise<void> {
  this.logger.log(payload);
  this.server.to(payload.roomName).emit('chat', payload); // broadcast messages
}
Enter fullscreen mode Exit fullscreen mode

One additional nicety here is that our Message type we’re giving the payload is also derived from our ChatMessageSchema which we saw earlier but we’ll show again below from our shared interfaces.

export type Message = z.infer<typeof ChatMessageSchema>
Enter fullscreen mode Exit fullscreen mode

If we wanted to add new fields to our chat messages, all we would need to do is update the ChatMessageSchema.

For users joining rooms, we’re also validating that data as well.

@UsePipes(new ZodValidationPipe(JoinRoomSchema))
@SubscribeMessage('join_room')
async handleSetClientDataEvent(
  @MessageBody()
  payload: JoinRoom,
): Promise<void> {
  if (payload.user.socketId) {
    this.logger.log(
      `${payload.user.socketId} is joining ${payload.roomName}`,
    );
    await this.server.in(payload.user.socketId).socketsJoin(payload.roomName);
    await this.userService.addUserToRoom(payload.roomName, payload.user);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s an example of a pipe throwing an exception. We had to modify the schema to show this, but this would happen given that the chat message is sent to the server in a malformed way:

[1] [Nest] 18224  - 12/10/2022, 1:51:47 PM   ERROR [WsExceptionsHandler] [
[1]   {
[1]     "code": "too_small",
[1]     "minimum": 2,
[1]     "type": "string",
[1]     "inclusive": true,
[1]     "message": "Must be at least 1 character.",
[1]     "path": [
[1]       "message"
[1]     ]
[1]   }
[1] ]
[1] ZodError: [
[1]   {
[1]     "code": "too_small",
[1]     "minimum": 2,
[1]     "type": "string",
[1]     "inclusive": true,
[1]     "message": "Must be at least 1 character.",
[1]     "path": [
[1]       "message"
[1]     ]
[1]   }
[1] ]
[1]     at handleResult (/Users/austinhoward/code/nest-realtime/nest-react-websockets/node_modules/zod/lib/types.js:29:23)
[1]     at ZodObject.safeParse (/Users/austinhoward/code/nest-realtime/nest-react-websockets/node_modules/zod/lib/types.js:140:16)
[1]     at ZodObject.parse (/Users/austinhoward/code/nest-realtime/nest-react-websockets/node_modules/zod/lib/types.js:120:29)
[1]     at ZodValidationPipe.transform (/Users/austinhoward/code/nest-realtime/nest-react-websockets/src/server/pipes/zod.pipe.ts:9:17)
[1]     at /Users/austinhoward/code/nest-realtime/nest-react-websockets/node_modules/@nestjs/core/pipes/pipes-consumer.js:16:33
[1]     at processTicksAndRejections (node:internal/process/task_queues:96:5)
Enter fullscreen mode Exit fullscreen mode

Client Validation

Previously we were not using any special libraries for the forms in our React client.

There are several good form libraries, notably React Hook Form and Formik. We’re going to use React Hook Form.

Let’s start with our login form. We’re using a mixture of React Hook Form validation and native HTML form validation techniques to give us a very robust form.

Login form UI component

client/components/login.form.tsx

import React from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import {
  RoomNameSchema,
  UserNameSchema,
  RoomNameSchemaRegex,
} from '../../shared/schemas/chat.schema'
import { generateUserId, setUser } from '../lib/user'

const formSchema = z.object({
  userName: UserNameSchema,
  roomName: RoomNameSchema.or(z.string().length(0))
    .optional()
    .transform((name) => (name === '' ? undefined : name)),
})

export type LoginFormInputs = z.infer<typeof formSchema>

export const LoginForm = ({
  onSubmitSecondary,
  disableNewRoom,
  defaultUser,
}: {
  onSubmitSecondary: (data: LoginFormInputs) => void
  disableNewRoom: boolean
  defaultUser?: string
}) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginFormInputs>({
    resolver: zodResolver(formSchema),
    mode: 'onChange',
  })

  const onSubmit = (data: LoginFormInputs) => {
    const newUser = {
      id: generateUserId(data.userName),
      name: data.userName,
    }
    setUser(newUser)
    onSubmitSecondary(data)
  }

  return (
    <div className="h-full w-full py-2 md:px-2 md:py-0">
      <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col justify-center">
        <input
          type="text"
          id="login"
          placeholder="Name"
          defaultValue={defaultUser && defaultUser}
          required={true}
          minLength={UserNameSchema.minLength ?? undefined}
          maxLength={UserNameSchema.maxLength ?? undefined}
          {...register('userName')}
          className="h-12 rounded-md border border-slate-400 bg-gray-800 text-white placeholder-slate-400 invalid:text-pink-600 invalid:ring-pink-600 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 focus:invalid:border-pink-600 focus:invalid:ring-pink-600 active:invalid:border-pink-600"
        ></input>
        <p className="py-1 text-sm text-pink-600">{errors.userName?.message}</p>
        <input
          type="text"
          id="room"
          required={!disableNewRoom}
          disabled={disableNewRoom}
          minLength={RoomNameSchema?.minLength ?? undefined}
          maxLength={RoomNameSchema?.maxLength ?? undefined}
          pattern={RoomNameSchemaRegex.source.toString()}
          placeholder="New room"
          {...register('roomName')}
          className="h-12 rounded-md border border-slate-400 bg-gray-800 text-white placeholder-slate-400 invalid:text-pink-600 invalid:ring-pink-600 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 focus:invalid:border-pink-600 focus:invalid:ring-pink-600 disabled:opacity-50"
        ></input>
        <p className="py-1 text-sm text-pink-600">{errors.roomName?.message}</p>

        <button
          type="submit"
          className="flex h-12 w-full items-center justify-center rounded-md bg-violet-700 text-white"
        >
          Join
        </button>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

We’ll go step by step through the form updates.

First, we’re constructing a schema specific to the form’s input values - formSchema.

The two form input values:

  • userName: takes the UserNameSchema from our shared interfaces.
  • roomName: takes the RoomNameSchema from our shared interfaces. Since this field is specifically for creating a new room, there is a scenario where the user instead selects an existing room instead of creating a new one, in which case we need to account for the roomName being a string of length 0. We handle this by chaining .or(z.string().length(0), with .optional(), and finally with a transform that will make the field undefined if the string is of length 0 .transform((name) => (name === '' ? undefined : name).
const formSchema = z.object({
  userName: UserNameSchema,
  roomName: RoomNameSchema.or(z.string().length(0))
    .optional()
    .transform((name) => (name === '' ? undefined : name)),
})
Enter fullscreen mode Exit fullscreen mode

We then create the form inputs type by inference.

export type LoginFormInputs = z.infer<typeof formSchema>
Enter fullscreen mode Exit fullscreen mode

Coming inside the form we need to bring in useForm from React Hook Form and pass in our LoginFormInputs type. We’re going to use the following return values and options:

Returns

  • register: allows us to register form input elements for validation.
  • handleSubmit: allows us to capture form data if validation is successful.
  • formState: contains information about the state of the form, we can use this to grab errors.

Options

  • resolver: which is going to handle our form validation. Since we’re using zod, we use zodResolver and pass in our formSchema.
  • mode: this option allows us to configure when the form validation will happen - in this case we’ve chosen onChange so that every time a change event happens on the form, we’re executing a validation.
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<LoginFormInputs>({
  resolver: zodResolver(formSchema),
  mode: 'onChange',
})
Enter fullscreen mode Exit fullscreen mode

We need to have a function to execute on submission as well - where we can also pass in a data argument of LoginFormInputs type.

const onSubmit = (data: LoginFormInputs) => {
  const newUser = {
    id: generateUserId(data.userName),
    name: data.userName,
  }
  setUser(newUser)
  onSubmitSecondary(data)
}
Enter fullscreen mode Exit fullscreen mode

Our name input form element also has some native form validation constraints which are required, minLength, and maxLength. We’re conveniently able to use our schema values for the length constraints which provides us extra validation protection.

We also render any errors below the input element.

<input
  type="text"
  id="login"
  placeholder="Name"
  defaultValue={defaultUser && defaultUser}
  required={true}
  minLength={UserNameSchema.minLength ?? undefined}
  maxLength={UserNameSchema.maxLength ?? undefined}
  {...register('userName')}
  className="h-12 rounded-md border border-slate-400 bg-gray-800 text-white placeholder-slate-400 invalid:text-pink-600 invalid:ring-pink-600 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 focus:invalid:border-pink-600 focus:invalid:ring-pink-600 active:invalid:border-pink-600"
></input>
<p className="py-1 text-sm text-pink-600">{errors.userName?.message}</p>
Enter fullscreen mode Exit fullscreen mode

Our room input element also uses some native form validation constraints. The new interesting one here is pattern which we’re also able to use from our RoomNameSchemaRegex.

<input
  type="text"
  id="room"
  required={!disableNewRoom}
  disabled={disableNewRoom}
  minLength={RoomNameSchema?.minLength ?? undefined}
  maxLength={RoomNameSchema?.maxLength ?? undefined}
  pattern={RoomNameSchemaRegex.source.toString()}
  placeholder="New room"
  {...register('roomName')}
  className="h-12 rounded-md border border-slate-400 bg-gray-800 text-white placeholder-slate-400 invalid:text-pink-600 invalid:ring-pink-600 focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 focus:invalid:border-pink-600 focus:invalid:ring-pink-600 disabled:opacity-50"
></input>
<p className="py-1 text-sm text-pink-600">{errors.roomName?.message}</p>
Enter fullscreen mode Exit fullscreen mode

Here are some snapshots of validation errors in our login form.

Invalid name input error screenshot

Invalid room input error screenshot

Room required input error screenshot

Forms are our first layer of protection from malformed data on in the client, but we have added one more secondary layer as well in our Chat page.

client/pages/chat.tsx

import React, { useState, useEffect } from 'react'
import { MakeGenerics, useMatch, useNavigate } from '@tanstack/react-location'
import { io, Socket } from 'socket.io-client'
import {
  User,
  Message,
  ServerToClientEvents,
  ClientToServerEvents,
} from '../../shared/interfaces/chat.interface'
import { Header } from '../components/header'
import { UserList } from '../components/list'
import { MessageForm } from '../components/message.form'
import { Messages } from '../components/messages'
import { ChatLayout } from '../layouts/chat.layout'
import { unsetRoom, useRoomQuery } from '../lib/room'
import { getUser } from '../lib/user'
import { ChatMessageSchema, JoinRoomSchema } from '../../shared/schemas/chat.schema'

const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({
  autoConnect: false,
})

function Chat() {
  const {
    data: { user, roomName },
  } = useMatch<ChatLocationGenerics>()

  const [isConnected, setIsConnected] = useState(socket.connected)
  const [messages, setMessages] = useState<Message[]>([])
  const [toggleUserList, setToggleUserList] = useState<boolean>(false)

  const { data: room } = useRoomQuery(roomName, isConnected)

  const navigate = useNavigate()

  useEffect(() => {
    if (!user || !roomName) {
      navigate({ to: '/', replace: true })
    } else {
      socket.on('connect', () => {
        const joinRoom = {
          roomName,
          user: { socketId: socket.id, ...user },
        }
        JoinRoomSchema.parse(joinRoom)
        socket.emit('join_room', joinRoom)
        setIsConnected(true)
      })

      socket.on('disconnect', () => {
        setIsConnected(false)
      })

      socket.on('chat', (e) => {
        setMessages((messages) => [e, ...messages])
      })

      socket.connect()
    }
    return () => {
      socket.off('connect')
      socket.off('disconnect')
      socket.off('chat')
    }
  }, [])

  const leaveRoom = () => {
    socket.disconnect()
    unsetRoom()
    navigate({ to: '/', replace: true })
  }

  const sendMessage = (message: string) => {
    if (user && socket && roomName) {
      const chatMessage = {
        user: {
          userId: user.userId,
          userName: user.userName,
          socketId: socket.id,
        },
        timeSent: new Date(Date.now()).toLocaleString('en-US'),
        message,
        roomName: roomName,
      }
      ChatMessageSchema.parse(chatMessage)
      socket.emit('chat', chatMessage)
    }
  }
  return (
    <>
      {user?.userId && roomName && room && (
        <ChatLayout>
          <Header
            isConnected={isConnected}
            users={room?.users ?? []}
            roomName={roomName}
            handleUsersClick={() => setToggleUserList((toggleUserList) => !toggleUserList)}
            handleLeaveRoom={() => leaveRoom()}
          ></Header>
          {toggleUserList ? (
            <UserList room={room}></UserList>
          ) : (
            <Messages user={user} messages={messages}></Messages>
          )}
          <MessageForm sendMessage={sendMessage}></MessageForm>
        </ChatLayout>
      )}
    </>
  )
}

export const loader = async () => {
  const user = getUser()
  return {
    user: user,
    roomName: sessionStorage.getItem('room'),
  }
}

type ChatLocationGenerics = MakeGenerics<{
  LoaderData: {
    user: Pick<User, 'userId' | 'userName'>
    roomName: string
  }
}>

export default Chat
Enter fullscreen mode Exit fullscreen mode

We went over the Chat page in depth in the previous series part so we’ll just focus on the schema validation additions.

Whenever a client socket connects, we emit a 'join_room' event. Now we’ve added a JoinRoomSchema.parse(joinRoom) which will throw an error if it fails the schema checks, otherwise the event will fire successfully.

socket.on('connect', () => {
  const joinRoom = {
    roomName,
    user: { socketId: socket.id, ...user },
  }
  JoinRoomSchema.parse(joinRoom)
  socket.emit('join_room', joinRoom)
  setIsConnected(true)
})
Enter fullscreen mode Exit fullscreen mode

Similarly in our sendMessage function we’re using ChatMessageSchema.parse(chatMessage) to validate the message data as well.

const sendMessage = (message: string) => {
  if (user && socket && roomName) {
    const chatMessage = {
      user: {
        userId: user.userId,
        userName: user.userName,
        socketId: socket.id,
      },
      timeSent: new Date(Date.now()).toLocaleString('en-US'),
      message,
      roomName: roomName,
    }
    ChatMessageSchema.parse(chatMessage)
    socket.emit('chat', chatMessage)
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s an example of a failure which is caught in the client before ever getting to the server. We had to modify the schema to simulate this error:

Client zod error log

Conclusion

This wraps up how we can use pipes, zod, and React Hook Form to achieve end-to-end validation of data.

We now have multiple layers of protection and can have greater confidence that our application will operate as intended.

We were able to write our schemas in one place and use them everywhere throughout our applications!

Top comments (0)