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!

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed