We use cookies to enhance your experience on the site
CodeWorlds

class-transformer - the art of transforming data

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:

  1. The service fetches data from the database.
  2. The controller returns an object from the service.
  3. ClassSerializerInterceptor
    applies
    @Exclude
    /
    @Expose
    .
  4. 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, @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,
  • for
    @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,
  • 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 →
    ClassSerializerInterceptor
    applies
    @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,
  • 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.

Go to CodeWorlds