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
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" }
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"
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:
-
excludeExtraneousValues
const dto = plainToInstance(MobileSignupBody, { email: 'abc@test.com' }, {
excludeExtraneousValues: true,
});
In this mode, only properties with @Expose() are kept.
Downside: you must decorate all properties you want to keep.
-
strategy: 'exposeAll'
@Expose({ strategy: 'exposeAll' })
class MobileSignupBody {
email!: string;
@Expose()
@Transform(({ obj }) => obj.email)
normalized_email?: string;
}
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;
}
}
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)