Nest JS Websockets - Pipes and E2E Validation with Zod
Tags: webdev, react, typescript, tutorial
Published: 2022-12-10 23:59:25

Welcome to **Part 3: Pipes and E2E Validation with Zod**, of building a realtime chat application with [Nest JS](https://nestjs.com/) and [React](https://reactjs.org/).

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

- [Part 1: Basics](https://www.delightfulengineering.com/blog/nest-websockets/basics) - laying the foundations of the application and implementing the most basic websockets features.
- [Part 2: Rooms](https://www.delightfulengineering.com/blog/nest-websockets/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](https://github.com/ahoward2/nest-react-websockets/tree/2f225e31e6bae789fd90206def3f0d95fd3aa759)** 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](https://socket.io/docs/v4/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](https://github.com/colinhacks/zod) and use [schema methods](https://github.com/colinhacks/zod#schema-methods) such as `parse` to do validation checks.
- Use those schemas to infer types rather than explicitly writing them.
- Create a custom [pipe](https://docs.nestjs.com/pipes#object-schema-validation) to do validation on the server with our schemas.
- Use [react-hook-form](https://react-hook-form.com/) 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**

```tsx
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,
})
```

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**

```tsx
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
}
```

## 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**

```tsx
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
  }
}
```

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

**server/chat/chat.gateway.ts**

```tsx
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}`)
  }
}
```

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.

```tsx
@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
}
```

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.

```tsx
export type Message = z.infer<typeof ChatMessageSchema>
```

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.

```tsx
@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);
  }
}
```

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:

```bash
[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)
```

## 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](https://react-hook-form.com/) and [Formik](https://formik.org/). 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yk0qjskls6jw5bawookg.png)

**client/components/login.form.tsx**

```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>
  )
}
```

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)`.

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

We then create the form inputs type by inference.

```tsx
export type LoginFormInputs = z.infer<typeof formSchema>
```

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.

```tsx
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<LoginFormInputs>({
  resolver: zodResolver(formSchema),
  mode: 'onChange',
})
```

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

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

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.

```tsx
<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>
```

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`.

```tsx
<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>
```

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

![Invalid name input error screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5461f7041uov162jmzf6.png)

![Invalid room input error screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/toob8ms1buz45ztooqo8.png)

![Room required input error screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p1xknhvxbexcco35mqs2.png)

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**

```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
```

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.

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

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

```tsx
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)
  }
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/63c6ascg5g8pse46it43.png)

## 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!