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.
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}
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 @Exclude()
senator.password still works.
does the reverse - it marks a field as visible. By default every field is visible, so @Expose()
@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.
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 @Transform()
value field and returns whatever should reach the response - here a date shortened to the day alone.The most important sentence of this lesson: the decorators alone do nothing. They are annotations; somebody must execute them.
For
and @Exclude
to work in a controller you must add @Expose
. Not install an additional @UseInterceptors(ClassSerializerInterceptor)
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 whole transformation runs in four steps, always in this order:
ClassSerializerInterceptor applies @Exclude/@Expose.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.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.A leak needs no bug; the absence of a decision suffices, @name:
@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 with excludeExtraneousValues: true,@Transform() defines custom transformation logic for a field's value - it does not animate, back up, or change a type,@Exclude and @Expose to work, the controller needs @UseInterceptors(ClassSerializerInterceptor) - not a class-serializer library, not middleware in app.module.ts, not a @Serialize() decorator,ClassSerializerInterceptor applies @Exclude/@Expose → the client receives filtered JSON,plainToInstance( → CreateLegionaryDto → , rawData → ): the class first, then the data,instanceToPlain works 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.