NestJS course Β· Module 10: Data Validation
class-transformer - the art of transforming data
In this lesson5
Validation watches what comes in. This lesson is about the opposite: what leaves your API, and in what form.
The problem is easiest to see through an example. The service fetches a senator entity from the database - with the password, the salary, the internal session identifier. The controller returns that object. NestJS turns it into JSON and sends it to the client entire, because nobody said any of it should be held back. A data leak needs no bug; the absence of a decision suffices.
Three decorators
class-transformer gives three tools for stating what is to happen to a field when an object becomes a response:
1export class SenatorResponseDto {
2 @Expose()
3 name: string;
4
5 @Expose()
6 province: string;
7
8 @Expose()
9 @Transform(({ value }) => value.toISOString().slice(0, 10))
10 appointedAt: Date;
11
12 @Exclude()
13 password: string;
14
15 @Exclude()
16 salary: number;
17}@Exclude() hides a field when the object is converted to a JSON response. It does not remove the field from the database - the entity is untouched. It does not disable validation for that field. And it does not block access to it in TypeScript; inside the service senator.password still works.
@Expose() does the reverse - it marks a field as visible. By default every field is visible, so @Expose() alone changes little; it takes on meaning with the excludeExtraneousValues: true option, which inverts the rule so that only what is explicitly exposed goes out.
@Transform() defines custom transformation logic for a field's value. It does not animate a change of value, does not create a backup, and does not change the field's type in TypeScript. The function receives an object with a value field and returns whatever should reach the response - here a date shortened to the day alone.
When this actually takes effect
The most important sentence of this lesson: the decorators alone do nothing. They are annotations; somebody must execute them.
For @Exclude and @Expose to work in a controller you must add @UseInterceptors(ClassSerializerInterceptor). Not install an additional class-serializer library - no such thing exists. Not configure middleware in app.module.ts. And there is no special @Serialize() decorator to put above the method.
1@Controller('senators')
2@UseInterceptors(ClassSerializerInterceptor)
3export class SenatorsController {
4 @Get(':id')
5 findOne(@Param('id') id: string) {
6 return this.senatorsService.findOne(id);
7 }
8}This is the class of mistake that gives no signal at all. The code compiles, the service's unit tests pass, and the passwords travel to the client - because nobody attached the interceptor. Checking takes half a minute: call the endpoint and read the response.
The interceptor can also be registered globally through app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))) - and with sensitive data that is the safer choice, because forgetting the decorator on one controller ceases to be possible.
The data's road from database to client
The whole transformation runs in four steps, always in this order:
- The service fetches data from the database.
- The controller returns an object from the service.
ClassSerializerInterceptorapplies@Exclude/@Expose.- The client receives filtered JSON.
Note where step three sits: after the controller, not before it. The interceptor acts on what the method returned - which is why the service and the controller work with the complete object, every field included. Filtering is the last act before sending, not a precondition of the earlier work.
Hence a practical conclusion: a password logged inside the service will appear, though it no longer appears in the response. @Exclude() protects the API's output, not your own logs.
Manual conversion
Sometimes you need to turn a plain object into a class instance without an interceptor - in a test, say, or with data off a queue:
1const dto = plainToInstance(CreateLegionaryDto, rawData);The order of the arguments is fixed: plainToInstance( β CreateLegionaryDto β , rawData β ). First the target class, then the raw data - the direction is easy to confuse, and swapping them yields an object that looks right and carries none of your decorators.
The inverse function, instanceToPlain, turns a class instance into a plain object, applying @Exclude and @Expose on the way. That is exactly the operation ClassSerializerInterceptor performs for you.
Summary
A leak needs no bug; the absence of a decision suffices:
@Exclude()hides a field when the object is converted to a JSON response - it does not remove it from the database, disable validation, or block access in TypeScript,@Expose()marks a field as visible; it matters withexcludeExtraneousValues: true,@Transform()defines custom transformation logic for a field's value - it does not animate, back up, or change a type,- for
@Excludeand@Exposeto work, the controller needs@UseInterceptors(ClassSerializerInterceptor)- not aclass-serializerlibrary, not middleware inapp.module.ts, not a@Serialize()decorator, - a missing interceptor produces no error at all - the data simply goes out whole,
- the data's road: the service fetches from the database β the controller returns an object β
ClassSerializerInterceptorapplies@Exclude/@Exposeβ the client receives filtered JSON, - filtering happens after the controller, so excluded fields still appear in the service's logs,
plainToInstance(βCreateLegionaryDtoβ, rawDataβ): the class first, then the data,instanceToPlainworks the other way - the same operation the interceptor performs.
In the next lesson we descend to nested validation. For now remember: what leaves your API is decided either by you or by accident. There is no third possibility.
Code for this lesson: src/class-transformer.ts
1// class-transformer - Data transformation
2import {
3 Exclude,
4 Expose,
5 Transform,
6 Type,
7 plainToInstance,
8 instanceToPlain,
9} from 'class-transformer';
10import { IsString, IsNumber } from 'class-validator';
11
12// TODO: Complete the transformation decorators
13
14export class LegionaryResponseDto {
15 // TODO: Add @Expose()
16 name: string;
17
18 // TODO: Add @Expose()
19 rank: string;
20
21 // TODO: Add @Expose() and @Transform
22 // Transform: if value > 10, return 'Veteranus', otherwise 'Tiro'
23 experienceYears: number;
24
25 // TODO: Add @Exclude() - hide secret code
26 secretMissionCode: string;
27
28 // TODO: Add @Exclude() - hide salary
29 salary: number;
30}
31
32export class CreateLegionaryDto {
33 @IsString()
34 @Transform(({ value }) => value.trim())
35 name: string;
36
37 @IsString()
38 rank: string;
39
40 @IsNumber()
41 age: number;
42}
43
44// Transformation test
45const rawData = {
46 name: ' Marcus Aurelius ',
47 rank: 'centurio',
48 age: 35,
49 secretMissionCode: 'ROMA-X-42',
50 salary: 5000,
51 experienceYears: 15,
52};
53
54console.log('Raw data:', rawData);
55console.log('After transformation, secret fields will be hidden');
56Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the @Exclude() decorator from the class-transformer library do?
2. What is the @Transform() decorator used for in class-transformer?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Add @Expose, @Exclude, and @Transform decorators to SenatorResponseDto
- Horizontal ordering
Arrange the elements of the plainToInstance function call in the correct order:
- Click in order
Arrange the steps of data transformation from the database to the API response:
- Code editor
Complete the missing decorators in CreateSoldierDto, UpdateSoldierDto, and SoldierResponseDto