DEV Community

Tim Nguyen
Tim Nguyen

Posted on

Why Your `@Transform` Decorator Doesn’t Run in class-transformer (and How to Fix It)

If you’ve ever used class-transformer with @Transform, you might have run into this weird situation:

import 'reflect-metadata';
import { plainToInstance, Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty } from 'class-validator';

class MobileSignupBody {
  @IsNotEmpty()
  @IsEmail()
  email!: string;

  @Transform(({ obj }) => obj.email)
  normalized_email?: string;
}

const dto = plainToInstance(MobileSignupBody, { email: 'abc@test.com' });
console.log(dto); // MobileSignupBody { email: 'abc@test.com' }
console.log(dto.normalized_email); // ❌ undefined
Enter fullscreen mode Exit fullscreen mode

Wait… why is normalized_email undefined even though I clearly added a @Transform?

The Gotcha

By default, class-transformer only runs @Transform on properties that exist in the plain object input.

In this case, the plain object only had:

{ "email": "abc@test.com" }
Enter fullscreen mode Exit fullscreen mode

Since there was no normalized_email key, class-transformer skipped that property completely.
That’s why the transform never fired.

The Fix: Use @Expose

You need to tell class-transformer: “always include this property, even if it’s not in the plain object.”

import { Expose, Transform } from 'class-transformer';

class MobileSignupBody {
  @Expose() // Will always include in class/plain object -> be transformed
  @IsNotEmpty()
  @IsEmail()
  email!: string;

  @Expose()
  @Transform(({ obj }) => obj.email)
  normalized_email?: string;
}

const dto = plainToInstance(MobileSignupBody, { email: 'abc@test.com' });
console.log(dto.normalized_email); // ✅ "abc@test.com"
Enter fullscreen mode Exit fullscreen mode

Now it works because @Expose forces normalized_email into the transform pipeline.

Global Config Options

If you don’t want to sprinkle @Expose() everywhere, you have a couple of global strategies:

  1. excludeExtraneousValues
const dto = plainToInstance(MobileSignupBody, { email: 'abc@test.com' }, {
  excludeExtraneousValues: true,
});
Enter fullscreen mode Exit fullscreen mode

In this mode, only properties with @Expose() are kept.
Downside: you must decorate all properties you want to keep.

  1. strategy: 'exposeAll'
@Expose({ strategy: 'exposeAll' })
class MobileSignupBody {
  email!: string;

  @Expose()
  @Transform(({ obj }) => obj.email)
  normalized_email?: string;
}
Enter fullscreen mode Exit fullscreen mode

This makes all properties exposed by default, and you only need @Expose() where you want transforms.

Alternative: Use a Getter

Sometimes, the simplest solution is just a computed property:

class MobileSignupBody {
  email!: string;

  get normalized_email(): string | undefined {
    return this.email;
  }
}
Enter fullscreen mode Exit fullscreen mode

No @Expose, no @Transform, always works.

Takeaways

@Transform only runs if the property exists in the plain object.
Use @Expose to force properties into the transformation pipeline.
For global behavior, use excludeExtraneousValues or strategy: 'exposeAll'. Or skip it all and use getters if you just want a derived property.

🔗 Useful refs:

class-transformer docs

class-validator docs


Have you ever hit this @Transform gotcha? How do you usually handle derived fields in DTOs — getters, transforms, or something else? Let me know 👇

Top comments (0)