I found no information about this, so here is a short description of my steps to get all of this together.
- 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.
- We used tsup for the packaging, it's a straightforward way to get it working
- Keep the dependencies of the packages neutral (no backend or frontend specific code)
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:
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;
};
🧙🏼♂️ ✨ Dependency injection ✨
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 {}
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 {}
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>
);
}
(I'll be using this same markdown for the repo README.md)