Solving the @Transform
Undefined Issue in class-transformer
If you’re building robust data transfer objects (DTOs) in TypeScript using class-transformer
and class-validator
, you’ve likely encountered the powerful @Transform
decorator. It allows you to modify property values during the transformation process, making it incredibly useful for normalization, data sanitization, or creating derived fields. However, a common pitfall often leads to unexpected undefined
values: @Transform
failing to execute for properties not present in your initial plain object.
This article dives into this “gotcha,” explains why it happens, and provides effective solutions, including the essential @Expose
decorator, global configuration options, and simple getter alternatives.
The Mystery of the Missing Transformation
Consider a scenario where you want to normalize an email address in a signup DTO. You might set up your class like this:
import 'reflect-metadata';
import { plainToInstance, Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty } from 'class-validator';
class UserSignupData {
@IsNotEmpty()
@IsEmail()
email!: string;
@Transform(({ obj }) => obj.email.toLowerCase()) // Intended transformation
normalizedEmail?: string;
}
const inputData = plainToInstance(UserSignupData, { email: '[email protected]' });
console.log(inputData.email); // Output: '[email protected]'
console.log(inputData.normalizedEmail); // Output: ❌ undefined
Despite clearly defining @Transform
for normalizedEmail
, the normalizedEmail
property remains undefined
. Why? This is a frequent source of confusion for developers new to class-transformer
‘s default behavior.
Unpacking the class-transformer
Default Behavior
The core reason for this behavior lies in class-transformer
‘s default strategy for plainToInstance
(and similar methods). By default, it primarily focuses on mapping properties that already exist in the source plain JavaScript object to the target class instance.
In our example, the input plain object was { email: '[email protected]' }
. It did not contain a normalizedEmail
key. Because normalizedEmail
wasn’t explicitly present in the input, class-transformer
effectively ignored it during the initial property mapping phase. Consequently, the @Transform
decorator associated with normalizedEmail
was never triggered.
The Solution: Introduce @Expose()
To ensure class-transformer
acknowledges and processes a property – even if it’s not in the source plain object – you need to explicitly tell it to do so using the @Expose()
decorator. @Expose()
marks a property as eligible for transformation and serialization/deserialization, effectively bringing it into the class-transformer
pipeline.
Let’s apply @Expose()
to our UserSignupData
class:
import { Expose, Transform, plainToInstance } from 'class-transformer';
import { IsEmail, IsNotEmpty } from 'class-validator';
import 'reflect-metadata';
class UserSignupData {
@Expose() // Ensure 'email' is always processed
@IsNotEmpty()
@IsEmail()
email!: string;
@Expose() // Now 'normalizedEmail' will be part of the transformation
@Transform(({ obj }) => obj.email.toLowerCase())
normalizedEmail?: string;
}
const inputData = plainToInstance(UserSignupData, { email: '[email protected]' });
console.log(inputData.email); // Output: '[email protected]'
console.log(inputData.normalizedEmail); // Output: ✅ '[email protected]'
With @Expose()
added to normalizedEmail
, class-transformer
now includes this property in the instance creation process, allowing the @Transform
decorator to execute and populate its value correctly. It’s often good practice to add @Expose()
to all properties that might be involved in transformations or validation, especially if you rely on class-transformer
‘s advanced features.
Global Configuration Options for @Expose()
Behavior
If your application involves many DTOs and you prefer not to add @Expose()
to every single property, class-transformer
offers global configuration strategies to manage exposure behavior:
1. excludeExtraneousValues: true
This option, when passed to plainToInstance
, ensures that only properties explicitly decorated with @Expose()
are included in the resulting class instance. Any properties from the plain object not marked with @Expose()
will be ignored.
const inputData = plainToInstance(UserSignupData, { email: '[email protected]', extraField: 'test' }, {
excludeExtraneousValues: true, // Only @Expose properties are kept
});
// 'extraField' would be dropped if not @Expose'd
While powerful for strict type adherence, this requires you to explicitly decorate all desired properties, which might be verbose in some cases.
2. strategy: 'exposeAll'
(Class-level)
You can apply a default exposure strategy directly to your class using @Expose({ strategy: 'exposeAll' })
. This effectively makes all properties within that class exposed by default. You would then only use @Expose()
on properties where you need a Transform
if the property isn’t directly coming from the input, or for custom serialization/deserialization logic.
import { Expose, Transform, plainToInstance } from 'class-transformer';
import { IsEmail, IsNotEmpty } from 'class-validator';
import 'reflect-metadata';
@Expose({ strategy: 'exposeAll' }) // All properties in this class are exposed by default
class UserSignupData {
@IsNotEmpty()
@IsEmail()
email!: string;
@Transform(({ obj }) => obj.email.toLowerCase())
normalizedEmail?: string;
}
const inputData = plainToInstance(UserSignupData, { email: '[email protected]' });
console.log(inputData.normalizedEmail); // ✅ '[email protected]' (Works because of exposeAll)
This strategy can significantly reduce boilerplate if most of your class properties need to be exposed.
An Alternative: Simple Getters for Derived Properties
For scenarios where a property is purely derived from other properties within the same class and doesn’t require complex transformation logic or explicit input from the plain object, a simple getter method can often be the most straightforward and readable solution.
class UserSignupData {
email!: string;
get normalizedEmail(): string | undefined {
return this.email?.toLowerCase(); // Derived property
}
}
const inputData = plainToInstance(UserSignupData, { email: '[email protected]' });
console.log(inputData.email); // Output: '[email protected]'
console.log(inputData.normalizedEmail); // Output: ✅ '[email protected]'
This approach requires no class-transformer
decorators for the derived property itself, making the code cleaner for simple computations.
Key Takeaways for Robust DTOs
@Transform
requires@Expose
: Always remember that@Transform
will only execute if the target property is either present in the input plain object or explicitly marked with@Expose()
.- Default
class-transformer
behavior: It prioritizes properties found in the source plain object. @Expose()
is your friend: Use it to include properties that might be derived, computed, or not always present in the incoming data but are essential for your class instance.- Global strategies: Leverage
excludeExtraneousValues
or@Expose({ strategy: 'exposeAll' })
for consistent exposure behavior across your DTOs. - Consider getters: For simple derived properties, a getter can offer a more concise and readable alternative to
@Transform
and@Expose
.
By understanding these nuances of class-transformer
, you can avoid common pitfalls and build more predictable and robust data transformation pipelines in your TypeScript applications.
Have you encountered similar challenges with @Transform
or other class-transformer
decorators? Share your experiences and preferred patterns for handling derived fields in DTOs in the comments below!