DEV Community

Cover image for Understanding Data Transfer Objects (DTO) and Data Validation in TypeScript (NestJS)
David Ekete
David Ekete

Posted on

Understanding Data Transfer Objects (DTO) and Data Validation in TypeScript (NestJS)

Data Transfer Objects (DTOs) are the basis of data validation in NestJS applications. DTOs enable various layers of flexible data validation in NestJS.

In this publication, you will take a deep dive into data transfer objects, discussing validation mechanisms, authentication models, and everything else you need to know about data transfer objects.

Prerequisites

This tutorial is not beginner-themed. If you’re just getting started with NestJS, read this article.

You’ll need to meet these requirements to fully understand this article.

  • Prior experience creating NestJS apps with MongoDB.
  • Competence with PostMan (recommended) or any other API testing tool.
  • Basic knowledge of bcrypt or the NodeJS crypto module.

Once you meet these requirements, we can get started.

What is a Data Transfer Object?

A data transfer object, commonly called a DTO, is an object used to validate data and define data structure sent into your Nest applications. DTOs are similar to interfaces, but differ from interfaces in the following ways:

  • Interfaces are used for type-checking and structure definition.
  • A DTO is used for type-checking, structure definition, and data validation.
  • Interfaces disappear during compilation, as it’s native to TypeScript and doesn't exist in JavaScript.
  • DTOs are defined using classes that are supported in native JavaScript. Hence, it remains after compilation.

DTOs alone can only do type-checking and structure definition. To run data validations using DTOs, you’ll need to use the NestJS ValidationPipe.

Validation Mechanisms

Pipes are used to validate data in NestJS. Data passing through a pipe is evaluated, if it passes the validation test, it is returned unmodified; otherwise, an error is thrown.

NestJS has 8 inbuilt pipes, but you’ll be focusing on the ValidationPipe which makes use of the class-validator package, because it abstracts a lot of verbose code and makes it easy to validate data using decorators.

DTOs in a Simple User Authentication Model

A user authentication model is a perfect example to demystify DTOs. This is because it requires multiple levels of data validation. Let’s make one and explore DTOs and the ValidationPipe to validate all forms of data coming into our application.

Setting up Development Environment

To set up your development environment:

  • Generate a new project using the Nest CLI,
  • Generate your Module, Service, and Controller using the CLI,
  • Install Mongoose and connect your application to the database,
  • Create your schema folder and define the Schema.

A typical user-auth schema should look like:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type UserDocument = User & Document;

@Schema()
export class User {
  @Prop({ required: true })
  fullName: string;

  @Prop({ required: true })
  email: string;

  @Prop({ required: true })
  password: string;
}
export const UserSchema = SchemaFactory.createForClass(User);
Enter fullscreen mode Exit fullscreen mode

Having the fullName, email, and password as required properties.

  • Create a folder inside your module and call it dto. This is where your dto classes will be stored and exported.
  • Create a file inside your dto folder and name it user.dto.ts.

Structure of a DTO

A DTO is a class, so it follows the same syntax a class does.

export class newUserDto {
  fullName: string;
  email: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Above is the structure of a basic DTO.

This structure is only useful for type-checking, it has no data validation properties.

Implementing Data Validation

To add validation functions; follow the steps below;

  • Inside your main.ts,
  • Import {ValidationPipe} from '@nestjs/common',
  • Inside the bootstrap function and directly below the app constant, call the useGlobalPipes method on app and pass in new ValidationPipe() as an argument.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import * as dotenv from 'dotenv';
dotenv.config();

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(process.env.PORT);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

This will enable auto-validation and ensure that all endpoints are protected from receiving incorrect data. The Validation pipe takes in an options object as an argument, you can find out more about it in the official documentation.

  • Install the class-validator and the class-transformer package by running:
npm i --save class-validator class-transformer
Enter fullscreen mode Exit fullscreen mode

The class-transformer converts JSON objects into an instance of your DTO class and vice-versa.

The class-validator package has a lot of validation queries, which you can find in their documentation, but you’ll be focusing on a few of them related to user data validation. In your user.dto.ts file, import the following queries from class-validator:

  • IsNotEmpty: This validator checks if a given value is empty. It takes in an object of options as an argument.
  • IsString: This validator checks if a given value is a real string. It takes in an object of options as an argument.
  • IsEmail: This validator checks if a given value is of type email. It takes in an object of options as an argument.
  • Length: This Validator takes in 3 arguments. The first is the minLength, then the maxLength, and finally an object of options.

The above will be used as decorators to validate their respective fields. Implement this in your DTO,

import { IsNotEmpty, IsEmail, Length, IsString } from 'class-validator';

export class newUserDto {
  @IsNotEmpty({ message: 'Please Enter Full Name' })
  @IsString({ message: 'Please Enter Valid Name' })
  fullName: string;

  @IsEmail({ message: 'Please Enter a Valid Email' })
  email: string;

  @Length(6, 50, {
    message: 'Password length Must be between 6 and 50 charcters',
  })
  password: string;
}

Enter fullscreen mode Exit fullscreen mode

The password field has only a Length validation but in production, you might need to add a few more fields to ensure the password is more secure.

These new validation fields might include checking if the password contains uppercase, lowercase, and numbers.

This can be done in several ways, including:

  • Padding with more validation decorators, like you did in the fullName field.
  • Using the @Matches validator and passing a regular expression that checks for your requirements as an argument.

But the cleanest way to do this would be by creating a custom validator to suit your specifications. You can learn more about custom decorators here.

Post & Put Requests

Test the data validation by making a few POST and PUT requests to your application. But before that, it’s bad practice to store your passwords in plain text. So hash them first before storing them in the database;

Hashing Passwords

You can hash the passwords using a package called bcrypt or the NodeJS crypto module. This publication covers bcrypt. To install bcrypt run

npm i bcrypt
npm i -D @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

Create a utils folder to store files that contain utility functions, like the function you’ll use to hash the passwords.

To ensure that user passwords are protected, you’ll need to hash them before they are stored in the database. Let’s implement this

  • Create a file in your utils folder, this will contain your utility functions.
  • Import * as bcrypt from 'bcrypt'
  • Create a function that takes in the raw password and hashes it using bcrypt then returns the hashed password. Export the function.
import * as bcrypt from 'bcrypt';

export async function hashPassword(textPassword: string) {
  const salt = await bcrypt.genSalt();
  const hash = await bcrypt.hash(textPassword, salt);
  return hash;
}
Enter fullscreen mode Exit fullscreen mode

There are different ways to use the bcrypt package, which you can find here.

Now that you have a function to hash our passwords, Implement your first POST request.

Creating a New User

To create a new user with valid credentials, you’ll need to import the DTO you created earlier into your service.

Service Logic

  • Create an async function newUser that takes in a parameter user which should be the data of the new user. Set its type to the DTO created earlier.
  • Import the hashPassword function.
  • Inside the async function, create a constant password, and assign the return value of awaiting the hashPassword function with user.password as an argument.
  • Return a new instance of your own version of my userModel as an argument pass in an object containing a destructured user and the password, and call the save() method on it.
import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { User, UserDocument } from './schemas/user.schema';
import { newUserDto } from './dto/user.dto';
import { hashPassword } from './utilis/bcrypt.utils';

@Injectable()
export class AuthService {
  constructor(
    @InjectModel(User.name)
    private userModel: Model<UserDocument>,
  ) {}

  async newUser(user: newUserDto): Promise<User> {
    const password = await hashPassword(user.password);
    return await new this.userModel({ ...user, password }).save();
  }
}
Enter fullscreen mode Exit fullscreen mode

The DTO and the validators we set up earlier will continuously check if the data is valid before storing the user data in the database.

Implement the controller logic so that you can test the DTO with some dummy data.

Controller Logic

import {
  Controller,
  Body,
  Post, 
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { newUserDto } from './dto/user.dto';

@Controller('users')
export class AuthController {
  constructor(private readonly service: AuthService) {}

  @Post('signup')
  async createUser(
    @Body()
    user: newUserDto,
  ): Promise<newUserDto> {
    return await this.service.newUser(user);
  }
Enter fullscreen mode Exit fullscreen mode

As you can see in the code block above, the user parameter has a type of newUserDto. This ensures that all data coming into the application matches the DTO, else it throws an error.

Testing Endpoints

Test the validation with some dummy data by making requests to http://localhost:3000/users/signup using PostMan or your preferred testing tool.

{
    "fullName":"Jon Snow",
    "email":"snow@housestark.com",
    "password":"password"
}
Enter fullscreen mode Exit fullscreen mode

The JSON data above checks all validation boxes. Hence, you’ll get a 201 response with data, with an _id property and a hashed password. Copy the _id property, you’ll need it when testing data validation in PUT requests.

{
    "fullName":"Arya Stark",
    "email":"aryathefaceless@housestark",
    "password":"password"
}
Enter fullscreen mode Exit fullscreen mode

The JSON data above has an invalid email property. Hence, you’ll get a 400 response with a message that describes the error. Save it to the database by updating the email property to aryathefaceless@housestark.com.

{
    "fullName":"Tyrion Lannister",
    "email":"tyrion@houselannister.com",
    "password":"pass",
     "house":"Lannister"
}
Enter fullscreen mode Exit fullscreen mode

The JSON data above has 2 problems, the password is too short (less than 6 characters) and there is an extra field house.

Increase the length of the password and send the request again. You’ll get a 200 request with the processed data, but the house field isn’t stored in the database because it was filtered when it was passed through the validation pipe.

Updating a User

Implement a PUT route to update user data when required.

Service Logic

async updateUser(id: string, userData: newUserDto): Promise<User> {
    return await this.userModel.findByIdAndUpdate(id, userData);
  }
Enter fullscreen mode Exit fullscreen mode

Controller Logic

@Put(':id')
  async updateuser(
    @Param('id')
    id: string,
    @Body()
    user: newUserDto,
  ): Promise<newUserDto> {
    return this.service.updateUser(id, user);
  }
Enter fullscreen mode Exit fullscreen mode

Similar to the POST request, the user is given a type of newUserDto. Hence, all data is checked thoroughly before it is saved in the database.

Test this endpoint by updating one of the users stored in your database. Recall that you copied the _id belonging to Jon Snow.

So make a PUT request to http://localhost:3000/id with the following data;

{
    "fullName":"Jon Snow",
    "email":"snow@housetargaryen.com",
    "password":"password"
}
Enter fullscreen mode Exit fullscreen mode

The data above checks all validation boxes, so it would return a 200 response. If any of the data fields contain invalid data, it would return a 400 response and the PUT request would fail.

Logging Users in

A user-authentication model wouldn't be complete if you couldn't log users in. To implement the logic for that;

Firstly, you’ll need a bcrypt utility function that will compare the hashed and plain text passwords. Like so,

export async function validatePassword(textPassword: string, hash: string) {
  const validUser = await bcrypt.compare(textPassword, hash);
  return validUser;
}
Enter fullscreen mode Exit fullscreen mode

Then, you’ll have to create a new DTO for the login data. The DTO is similar to the newUserDto but without the fullName property.

import { IsEmail } from 'class-validator';

export class loginUserDto {
  @IsEmail({ message: 'Please Enter a Valid Email' })
  email: string;

  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Notice that no validator was added to the password property. This is because it could pose a security threat in the future, as it exposes the password length range to malicious users.

Service Logic

async loginUser(loginData: loginUserDto) {
    const email = loginData.email;
    const user = await this.userModel.findOne({ email });
    if (user) {
      const valid = await validatePassword(loginData.password, user.password);
      if (valid) {
        return user;
      }
    }
    return new UnauthorizedException('Invalid Credentials');
  }
Enter fullscreen mode Exit fullscreen mode

Your service logic should be able to,

Check if the user exists;

  • If the user exists, validate the passwords
  • If the user doesn't exist, throw an exception

Validate Passwords;

  • If the passwords match, return the user
  • If the passwords don’t match, throw an exception

Controller Logic

@Post('login')
  async loginUser(
    @Body()
    loginData: loginUserDto,
  ) {
    return await this.service.loginUser(loginData);
  }
Enter fullscreen mode Exit fullscreen mode

Your controller should make the POST request to http://localhost:3000/users/login.

Conclusion

You’re finally at the end of this article. Here’s a recap what you’ve covered.

  • What a DTO is,
  • Differences between a DTO and an interface,
  • Structure of a DTO,
  • NestJS validation mechanism,
  • Making a simple user-authentication model with bcrypt.

That’s quite a lot, congratulations on making it this far.

You can find the code on Github.

Happy Coding!

Top comments (0)