Use @nesjs DTOs on client & backend

I found no information about this, so here is a short description of my steps to get all of this together.

Advantages of packaging DTOs in a separate package:

  • Prevents duplication between multiple apps.
  • Consistency, if the DTO validations change, you’ll get that changes in all your apps. So:
    • When you modify the expected payload for a given controller, this will be in sync with the clients using this package.
  • You can also use it on your clients, allowing frontends to run the same validations. That prevent unnecessary API calls.

Tips:

  1. We used tsup for the packaging, it’s a straightforward way to get it working
  2. Keep the dependencies of the packages neutral (no backend or frontend specific code)

Our implementation:

We use ApiProperty from from @nestjs/swagger but this has a dependency with @nestjs/core and some extra stuff that is only server related.

Our way to solve this was to create a function that takes ApiProperty decorator as an optional parameter. We set the default value as an empty function (a no-effect decorator).

This is our implementation:

Packaged DTO (runs in front and backend):

import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";

export const getCreateUserDto = (ApiPropertySwagger?: any) => {
  // We did this to avoid having to include all nest dependencies related to ApiProperty on the client side too
  // With this approach the value of this decorator will be injected by the server but wont affect the client
  const ApiProperty = ApiPropertySwagger || function () {};

  class CreateUserDto {
    @IsEmail()
    @ApiProperty({
      description: "This is required and must be a valid email",
      type: String,
    })
    email: string;

    @IsString()
    @MinLength(2)
    @ApiProperty({
      description: "This is required and must be at least 2 characters long",
      type: String,
    })
    firstName: string;

    @IsString()
    @IsOptional()
    lastName?: string;

    @IsString()
    @IsOptional()
    nationality?: string;
  }

  return CreateUserDto;
};

Wait, does it work?

πŸ§™πŸΌβ€β™‚οΈ ✨ Dependency injection ✨

Here is an screenshot of the swagger api doc working

Backend usage:

After doing the following in tour DTO file, you can use it as any other NestJs DTO

import { getCreateUserDto } from '@sample/dtos';
import { ApiProperty } from '@nestjs/swagger';
// Here we send `ApiProperty` dependency to  be added to`CreateUserDto`
export const _CreateUserDto = getCreateUserDto(ApiProperty);

// This allows using it as a TS type and as a constructor class
export class CreateUserDto extends _CreateUserDto {} 

Client usage:

import { getCreateUserDto } from "@sample/dtos";

// We don't need `ApiProperty` on the client,
// so it will fallback on the default empty decorator 
const _CreateUserDto = getCreateUserDto();
// This allows using it as a TS type and as a constructor class
class CreateUserDto extends _CreateUserDto {}

Use the DTOs in the frontend:

If we go int NestJS implementation of DTOs, we’ll see that they use class-validator, so we can use react-hook-forms + @hookform/resolvers/class-validator to use them as validators for our forms:

import { getCreateUserDto } from "@sample/dtos";
import { useForm } from "react-hook-form";
import { classValidatorResolver } from "@hookform/resolvers/class-validator";
import {
  FormControl,
  FormLabel,
  FormErrorMessage,
  FormHelperText,
  ChakraProvider,
  Flex,
  Input,
  Button,
  theme,
  Heading,
} from "@chakra-ui/react";
import get from "lodash.get";
import { useState } from "react";

// We don't need `ApiProperty` on the client,
// so it will fallback on the default empty decorator
const _CreateUserDto = getCreateUserDto();
// This allows using it as a TS type and as a constructor class
class CreateUserDto extends _CreateUserDto {}

const resolver = classValidatorResolver(CreateUserDto);

export default function Web() {
  const {
    watch,
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<CreateUserDto>({
    resolver,
    shouldFocusError: false,
  });

  const submitData = (validatedData: CreateUserDto) => {
    postUser(validatedData);
  };

  const [nestResponse, setNestResponse] = useState<any>(null);

  const emailError = get(errors, "email.message");
  const firstNameError = get(errors, "firstName.message");
  const lastNameError = get(errors, "lastName.message");
  const nationalityError = get(errors, "nationality.message");

  const notValidatedData = watch();

  const postUser = async (user: CreateUserDto) => {
    fetch("http://localhost:4000/users", {
      method: "POST",
      mode: "cors",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(user),
    })
      .then((response) => response.json())
      .then((data) => {
        setNestResponse(data);
      });
  };

  return (
    <ChakraProvider theme={theme}>
      <Flex
        as="form"
        onSubmit={handleSubmit(submitData)}
        noValidate
        flexDir={"column"}
        p={4}
      >
        <FormControl isInvalid={Boolean(emailError)}>
          <FormLabel>Email address</FormLabel>
          <Input type="email" {...register("email")} />
          {!emailError && <FormHelperText>share your email.</FormHelperText>}
          <FormErrorMessage>{emailError}</FormErrorMessage>
        </FormControl>
        <FormControl isInvalid={Boolean(firstNameError)}>
          <FormLabel>First Name</FormLabel>
          <Input {...register("firstName")} />
          {!firstNameError && <FormHelperText>type your name.</FormHelperText>}
          <FormErrorMessage>{firstNameError}</FormErrorMessage>
        </FormControl>
        <FormControl isInvalid={Boolean(lastNameError)}>
          <FormLabel>Last name</FormLabel>
          <Input {...register("lastName")} />
          {!lastNameError && <FormHelperText>this is optional.</FormHelperText>}
          <FormErrorMessage>{lastNameError}</FormErrorMessage>
        </FormControl>
        <FormControl isInvalid={Boolean(nationalityError)}>
          <FormLabel>Nationality</FormLabel>
          <Input {...register("nationality")} />
          {!nationalityError && <FormHelperText>πŸ‡ΊπŸ‡Ύ</FormHelperText>}
          <FormErrorMessage>{nationalityError}</FormErrorMessage>
        </FormControl>

        <Button bg="green.500" color="white" type="submit" w="fit-content">
          Run DTO validation in the client + in the server
        </Button>
      </Flex>
      <Flex p="4" flexDir={"column"}>
        <Button
          bg="red"
          onClick={() => postUser(notValidatedData)}
          w="fit-content"
        >
          Run DTO only in the server
        </Button>
        <Heading>Controller response: </Heading>
        <pre>{JSON.stringify(nestResponse, null, 2)}</pre>
      </Flex>
    </ChakraProvider>
  );
}

And that’s it Github repo:

(I’ll be using this same markdown for the repo README.md)

https://github.com/facundop3/poc-use-nestjs-dto-on-clients

GitHub

View Github