The om-data-mapper transformer module provides two powerful APIs for transforming objects:
- Decorator API - Modern, high-performance API using TC39 Stage 3 decorators (Recommended)
- class-transformer Compatibility API - Drop-in replacement for class-transformer with 10x better performance
Both APIs use JIT compilation for maximum performance.
npm install om-data-mapper
# or
pnpm add om-data-mapper
# or
yarn add om-data-mapperNo additional dependencies required - unlike class-transformer, you don't need reflect-metadata.
import { Mapper, Map, MapFrom, plainToInstance } from 'om-data-mapper';
type UserSource = {
firstName: string;
lastName: string;
age: number;
};
type UserDTO = {
fullName: string;
isAdult: boolean;
};
@Mapper<UserSource, UserDTO>()
class UserMapper {
@MapFrom((src: UserSource) => `${src.firstName} ${src.lastName}`)
fullName!: string;
@MapFrom((src: UserSource) => src.age >= 18)
isAdult!: boolean;
}
const source = { firstName: 'John', lastName: 'Doe', age: 30 };
const result = plainToInstance(UserMapper, source);
// { fullName: 'John Doe', isAdult: true }import { plainToClass, Expose, Type, Transform } from 'om-data-mapper/class-transformer-compat';
class UserDTO {
@Expose({ name: 'firstName' })
name: string;
@Expose()
@Transform(({ value }) => value >= 18)
isAdult: boolean;
}
const plain = { firstName: 'John', age: 30 };
const user = plainToClass(UserDTO, plain);
// UserDTO { name: 'John', isAdult: true }import {
Mapper,
Map,
MapFrom,
MapNested,
Transform,
Default,
When,
Ignore,
plainToInstance,
plainToInstanceArray,
tryPlainToInstance,
createMapper,
getMapper
} from 'om-data-mapper';Class decorator that marks a class as a mapper.
@Mapper<UserSource, UserDTO>()
class UserMapper {
// Property mappings...
}
// With options
@Mapper<UserSource, UserDTO>({ unsafe: true })
class UnsafeUserMapper {
// No error handling - maximum performance
}Options:
unsafe?: boolean- Disable error handling for maximum performanceuseUnsafe?: boolean- Alias forunsafe
Maps a property from a source path.
@Mapper<Source, Target>()
class UserMapper {
// Simple mapping
@Map('firstName')
name!: string;
// Nested path
@Map('user.profile.email')
email!: string;
// Deep nesting
@Map('data.user.address.city')
city!: string;
}Maps a property using a custom transformation function.
@Mapper<Source, Target>()
class UserMapper {
// Combine fields
@MapFrom((src: Source) => `${src.firstName} ${src.lastName}`)
fullName!: string;
// Boolean transformation
@MapFrom((src: Source) => src.age >= 18)
isAdult!: boolean;
// Complex logic
@MapFrom((src: Source) => {
const total = src.items.reduce((sum, item) => sum + item.price, 0);
return total * 1.1; // Add 10% tax
})
totalWithTax!: number;
}Transforms the value after mapping.
@Mapper<Source, Target>()
class UserMapper {
@Map('name')
@Transform((value: string) => value.toUpperCase())
name!: string;
@Map('price')
@Transform((value: number) => value.toFixed(2))
price!: string;
@Map('tags')
@Transform((value: string[]) => value.join(', '))
tagsString!: string;
}Chaining: @Transform can be chained with @Map or @MapFrom.
Provides a default value if the source value is undefined.
@Mapper<Source, Target>()
class UserMapper {
@Map('score')
@Default(0)
score!: number;
@Map('status')
@Default('active')
status!: string;
@MapFrom((src) => src.premium?.features)
@Default([])
features!: string[];
}Conditionally maps a property.
@Mapper<Source, Target>()
class UserMapper {
@When((src: Source) => src.isPremium)
@Map('premiumFeatures')
features?: string[];
@When((src: Source) => src.age >= 18)
@Map('adultContent')
adultContent?: string;
@When((src: Source) => src.role === 'admin')
@MapFrom((src) => src.adminData)
adminData?: any;
}Ignores a property (won't be mapped).
@Mapper<Source, Target>()
class UserMapper {
@Map('name')
name!: string;
@Ignore()
internalField!: string; // Won't be mapped
}Maps nested objects using another mapper.
class Address {
street: string;
city: string;
}
@Mapper<AddressSource, Address>()
class AddressMapper {
@Map('street')
street!: string;
@Map('city')
city!: string;
}
@Mapper<UserSource, UserDTO>()
class UserMapper {
@Map('name')
name!: string;
@MapNested(AddressMapper)
address!: Address;
}Transforms a plain object to a class instance.
const result = plainToInstance(UserMapper, source);
// Returns: UserDTOTransforms an array of plain objects.
const results = plainToInstanceArray(UserMapper, sources);
// Returns: UserDTO[]Transforms with error information.
const { result, errors } = tryPlainToInstance(UserMapper, source);
if (errors.length > 0) {
console.log('Transformation errors:', errors);
}Creates a mapper instance for reuse.
const mapper = createMapper(UserMapper);
const result1 = mapper.transform(source1);
const result2 = mapper.transform(source2);Gets or creates a singleton mapper instance.
const mapper = getMapper(UserMapper);
const result = mapper.transform(source);type ProductSource = {
id: string;
name: string;
price: number;
discount?: number;
category: {
id: string;
name: string;
};
tags: string[];
};
type ProductDTO = {
id: string;
name: string;
finalPrice: number;
categoryName: string;
tagsString: string;
isOnSale: boolean;
};
@Mapper<ProductSource, ProductDTO>()
class ProductMapper {
@Map('id')
id!: string;
@Map('name')
@Transform((v: string) => v.toUpperCase())
name!: string;
@MapFrom((src: ProductSource) => {
const discount = src.discount || 0;
return src.price * (1 - discount / 100);
})
finalPrice!: number;
@Map('category.name')
categoryName!: string;
@Map('tags')
@Transform((tags: string[]) => tags.join(', '))
tagsString!: string;
@MapFrom((src: ProductSource) => (src.discount || 0) > 0)
isOnSale!: boolean;
}
const product = {
id: '123',
name: 'laptop',
price: 1000,
discount: 10,
category: { id: 'cat1', name: 'Electronics' },
tags: ['new', 'featured']
};
const dto = plainToInstance(ProductMapper, product);
// {
// id: '123',
// name: 'LAPTOP',
// finalPrice: 900,
// categoryName: 'Electronics',
// tagsString: 'new, featured',
// isOnSale: true
// }@Mapper<AddressSource, AddressDTO>()
class AddressMapper {
@Map('street')
street!: string;
@Map('city')
city!: string;
@Map('zipCode')
zip!: string;
}
@Mapper<UserSource, UserDTO>()
class UserMapper {
@MapFrom((src) => `${src.firstName} ${src.lastName}`)
fullName!: string;
@Map('email')
email!: string;
@MapNested(AddressMapper)
address!: AddressDTO;
@MapFrom((src) => src.age >= 18)
isAdult!: boolean;
@When((src) => src.isPremium)
@Map('premiumFeatures')
features?: string[];
}@Mapper<Source, Target>()
class OrderMapper {
@Map('orderId')
id!: string;
@Map('total')
@Default(0)
total!: number;
@Map('status')
@Default('pending')
status!: string;
@When((src) => src.isPaid)
@Map('paymentMethod')
paymentMethod?: string;
@When((src) => src.isShipped)
@Map('trackingNumber')
trackingNumber?: string;
@MapFrom((src) => src.items?.length || 0)
itemCount!: number;
}import {
plainToClass,
plainToInstance,
plainToClassFromExist,
classToPlain,
instanceToPlain,
classToClass,
instanceToInstance,
serialize,
deserialize,
deserializeArray,
Expose,
Exclude,
Type,
Transform,
TransformClassToPlain,
TransformClassToClass,
TransformPlainToClass
} from 'om-data-mapper/class-transformer-compat';Marks a property to be exposed during transformation.
class UserDTO {
@Expose()
id: number;
@Expose({ name: 'userName' })
name: string;
@Expose({ groups: ['admin'] })
email: string;
@Expose({ since: 2.0, until: 3.0 })
legacyField: string;
}Options:
name?: string- Map from different property namegroups?: string[]- Only expose in specific groupssince?: number- Expose starting from versionuntil?: number- Expose until versiontoClassOnly?: boolean- Only when transforming to classtoPlainOnly?: boolean- Only when transforming to plain
Excludes a property from transformation.
class UserDTO {
@Expose()
name: string;
@Exclude()
password: string;
@Exclude({ toPlainOnly: true })
internalId: number;
}Options:
toClassOnly?: boolean- Only exclude when transforming to classtoPlainOnly?: boolean- Only exclude when transforming to plain
Specifies the type for nested transformations.
class Address {
@Expose()
street: string;
@Expose()
city: string;
}
class UserDTO {
@Expose()
name: string;
@Expose()
@Type(() => Address)
address: Address;
@Expose()
@Type(() => Date)
createdAt: Date;
}
const plain = {
name: 'John',
address: { street: '123 Main St', city: 'NYC' },
createdAt: '2024-01-01'
};
const user = plainToClass(UserDTO, plain);
console.log(user.address instanceof Address); // true
console.log(user.createdAt instanceof Date); // trueTransforms the value using a custom function.
class UserDTO {
@Expose()
@Transform(({ value }) => value.toUpperCase())
name: string;
@Expose()
@Transform(({ value }) => new Date(value))
createdAt: Date;
@Expose()
@Transform(({ value, obj }) => value + obj.bonus)
totalScore: number;
}Transform Function Parameters:
value- The property valuekey- The property keyobj- The source objecttype- Transformation type ('plainToClass' | 'classToPlain' | 'classToClass')options- Transformation options
Options:
toClassOnly?: boolean- Only apply when transforming to classtoPlainOnly?: boolean- Only apply when transforming to plain
Converts a plain object to a class instance.
const user = plainToClass(UserDTO, plainObject);Alias for plainToClass.
const user = plainToInstance(UserDTO, plainObject);Updates an existing instance with plain object data.
const user = new UserDTO();
plainToClassFromExist(user, plainObject);Converts a class instance to a plain object.
const plain = classToPlain(userInstance);Alias for classToPlain.
const plain = instanceToPlain(userInstance);Creates a deep clone of a class instance.
const clone = classToClass(userInstance);Alias for classToClass.
const clone = instanceToInstance(userInstance);Serializes a class instance to JSON string.
const json = serialize(userInstance);Deserializes JSON string to class instance.
const user = deserialize(UserDTO, jsonString);Deserializes JSON array to class instances.
const users = deserializeArray(UserDTO, jsonArrayString);interface ClassTransformOptions {
strategy?: 'excludeAll' | 'exposeAll';
excludeExtraneousValues?: boolean;
groups?: string[];
version?: number;
excludePrefixes?: string[];
ignoreDecorators?: boolean;
enableImplicitConversion?: boolean;
enableCircularCheck?: boolean;
exposeUnsetFields?: boolean;
}const user = plainToClass(UserDTO, plain, {
excludeExtraneousValues: true, // Only @Expose properties
groups: ['admin'], // Only admin group
version: 2.0 // Version 2.0 properties
});class Photo {
@Expose()
url: string;
@Expose()
@Type(() => Date)
uploadedAt: Date;
}
class Album {
@Expose()
name: string;
@Expose()
@Type(() => Photo)
photos: Photo[];
}
class UserDTO {
@Expose()
name: string;
@Expose()
@Type(() => Album)
albums: Album[];
}
const plain = {
name: 'John',
albums: [
{
name: 'Vacation',
photos: [
{ url: 'photo1.jpg', uploadedAt: '2024-01-01' },
{ url: 'photo2.jpg', uploadedAt: '2024-01-02' }
]
}
]
};
const user = plainToClass(UserDTO, plain);
console.log(user.albums[0].photos[0] instanceof Photo); // true
console.log(user.albums[0].photos[0].uploadedAt instanceof Date); // trueclass UserDTO {
@Expose({ groups: ['user', 'admin'] })
id: number;
@Expose({ groups: ['user', 'admin'] })
name: string;
@Expose({ groups: ['admin'] })
email: string;
@Expose({ since: 2.0 })
newFeature: string;
@Expose({ until: 2.0 })
legacyField: string;
}
// Transform for regular users
const userView = plainToClass(UserDTO, plain, { groups: ['user'] });
// { id, name } - no email
// Transform for admins
const adminView = plainToClass(UserDTO, plain, { groups: ['admin'] });
// { id, name, email }
// Transform for version 2.0
const v2View = plainToClass(UserDTO, plain, { version: 2.0 });
// Includes newFeature, excludes legacyFieldclass ProductDTO {
@Expose()
@Transform(({ value }) => value.toUpperCase())
name: string;
@Expose()
@Transform(({ value }) => parseFloat(value.toFixed(2)))
price: number;
@Expose()
@Transform(({ value }) => value.map((tag: string) => tag.toLowerCase()))
tags: string[];
@Expose()
@Transform(({ value, obj }) => {
const discount = obj.discount || 0;
return value * (1 - discount / 100);
})
finalPrice: number;
}The compatibility API is 100% compatible with class-transformer. Simply change the import:
// Before
import { plainToClass, Expose, Type } from 'class-transformer';
// After
import { plainToClass, Expose, Type } from 'om-data-mapper/class-transformer-compat';Benefits:
- ✅ 10x better performance
- ✅ No reflect-metadata dependency
- ✅ Same API - no code changes needed
- ✅ Full TypeScript support
| Operation | class-transformer | om-data-mapper | Speedup |
|---|---|---|---|
| Simple transformation | 326K ops/sec | 3.2M ops/sec | 10x |
| Nested objects | 80K ops/sec | 800K ops/sec | 10x |
| Array transformation | 50K ops/sec | 500K ops/sec | 10x |
| Complex transformations | 150K ops/sec | 1.5M ops/sec | 10x |
// ✅ New projects: Use Decorator API
@Mapper<Source, Target>()
class UserMapper { ... }
// ✅ Migrating from class-transformer: Use Compatibility API
class UserDTO {
@Expose()
name: string;
}// ✅ Good: Reuse mapper
const mapper = createMapper(UserMapper);
const result1 = mapper.transform(source1);
const result2 = mapper.transform(source2);
// ❌ Bad: Create new mapper each time
const result1 = plainToInstance(UserMapper, source1);
const result2 = plainToInstance(UserMapper, source2);// ✅ Good: Type-safe
@Mapper<UserSource, UserDTO>()
class UserMapper {
@MapFrom((src: UserSource) => src.firstName)
name!: string;
}
// ❌ Bad: No type safety
@Mapper()
class UserMapper {
@MapFrom((src: any) => src.firstName)
name!: string;
}// ✅ Good: Reusable nested mapper
@MapNested(AddressMapper)
address!: Address;
// ❌ Bad: Inline transformation
@MapFrom((src) => ({
street: src.address.street,
city: src.address.city
}))
address!: Address;// ✅ Good: Simple condition
@When((src) => src.isPremium)
@Map('features')
features?: string[];
// ❌ Bad: Complex condition (use @MapFrom instead)
@When((src) => src.role === 'admin' && src.permissions.includes('read'))
@Map('data')
data?: any;// API Response
type ApiResponse = {
user_id: string;
first_name: string;
last_name: string;
email_address: string;
created_at: string;
is_active: boolean;
};
// Frontend DTO
type UserDTO = {
id: string;
fullName: string;
email: string;
createdAt: Date;
isActive: boolean;
};
@Mapper<ApiResponse, UserDTO>()
class ApiResponseMapper {
@Map('user_id')
id!: string;
@MapFrom((src) => `${src.first_name} ${src.last_name}`)
fullName!: string;
@Map('email_address')
email!: string;
@Map('created_at')
@Transform((value: string) => new Date(value))
createdAt!: Date;
@Map('is_active')
isActive!: boolean;
}// Form Data
type FormData = {
name: string;
email: string;
phone: string;
agreeToTerms: boolean;
};
// API Payload
type ApiPayload = {
user_name: string;
contact_email: string;
contact_phone: string;
terms_accepted: boolean;
created_at: string;
};
@Mapper<FormData, ApiPayload>()
class FormToApiMapper {
@Map('name')
user_name!: string;
@Map('email')
contact_email!: string;
@Map('phone')
contact_phone!: string;
@Map('agreeToTerms')
terms_accepted!: boolean;
@MapFrom(() => new Date().toISOString())
created_at!: string;
}// Database Entity
type UserEntity = {
id: number;
username: string;
email: string;
password_hash: string;
created_at: Date;
updated_at: Date;
role: string;
};
// Public DTO (exclude sensitive data)
type PublicUserDTO = {
id: number;
username: string;
email: string;
memberSince: string;
role: string;
};
@Mapper<UserEntity, PublicUserDTO>()
class UserEntityMapper {
@Map('id')
id!: number;
@Map('username')
username!: string;
@Map('email')
email!: string;
@Map('created_at')
@Transform((date: Date) => date.toLocaleDateString())
memberSince!: string;
@Map('role')
role!: string;
// password_hash is automatically excluded
}type OrderItem = {
productId: string;
quantity: number;
price: number;
};
type OrderSource = {
orderId: string;
items: OrderItem[];
taxRate: number;
shippingCost: number;
};
type OrderSummary = {
orderId: string;
itemCount: number;
subtotal: number;
tax: number;
shipping: number;
total: number;
};
@Mapper<OrderSource, OrderSummary>()
class OrderSummaryMapper {
@Map('orderId')
orderId!: string;
@MapFrom((src) => src.items.length)
itemCount!: number;
@MapFrom((src) => src.items.reduce((sum, item) => sum + item.price * item.quantity, 0))
subtotal!: number;
@MapFrom((src) => {
const subtotal = src.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * src.taxRate;
})
tax!: number;
@Map('shippingCost')
shipping!: number;
@MapFrom((src) => {
const subtotal = src.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = subtotal * src.taxRate;
return subtotal + tax + src.shippingCost;
})
total!: number;
}Problem: Properties are undefined in the result.
// ❌ Problem
@Map('user.name')
name!: string;
const result = plainToInstance(UserMapper, { user: null });
// result.name is undefinedSolution: Use @Default() or handle in transformer.
// ✅ Solution 1: Default value
@Map('user.name')
@Default('Unknown')
name!: string;
// ✅ Solution 2: Handle in transformer
@MapFrom((src) => src.user?.name || 'Unknown')
name!: string;Problem: Types don't match expected format.
// ❌ Problem
@Map('createdAt')
createdAt!: Date;
const result = plainToInstance(UserMapper, { createdAt: '2024-01-01' });
// result.createdAt is a string, not a DateSolution: Use @Transform() to convert types.
// ✅ Solution
@Map('createdAt')
@Transform((value: string) => new Date(value))
createdAt!: Date;Problem: Nested objects remain as plain objects.
// ❌ Problem
@Map('address')
address!: Address;
const result = plainToInstance(UserMapper, source);
// result.address is a plain object, not an Address instanceSolution: Use @MapNested() or @Type().
// ✅ Solution 1: Decorator API
@MapNested(AddressMapper)
address!: Address;
// ✅ Solution 2: Compatibility API
@Type(() => Address)
address!: Address;Problem: Transformations are slow.
Solution: Reuse mapper instances and enable unsafe mode.
// ✅ Solution 1: Reuse mapper
const mapper = createMapper(UserMapper);
// Use mapper.transform() multiple times
// ✅ Solution 2: Unsafe mode (no error handling)
@Mapper<Source, Target>({ unsafe: true })
class FastUserMapper { ... }Problem: Objects with circular references cause stack overflow.
Solution: Avoid circular references or handle manually.
// ✅ Solution: Break circular reference
@MapFrom((src) => {
const { parent, ...rest } = src;
return rest;
})
data!: any;Ensure your tsconfig.json is configured correctly:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"experimentalDecorators": false,
"useDefineForClassFields": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}Important:
- ✅
experimentalDecorators: false- Use TC39 Stage 3 decorators - ✅
useDefineForClassFields: true- Required for decorators - ✅
target: "ES2022"- For optional chaining support
Class Decorators:
@Mapper<Source, Target>(options?)- Mark class as mapper
Property Decorators:
@Map(sourcePath)- Map from source path@MapFrom(transformer)- Map using transformer function@Transform(transformer)- Transform value after mapping@Default(value)- Provide default value@When(condition)- Conditional mapping@Ignore()- Ignore property@MapNested(MapperClass)- Map nested object
Functions:
plainToInstance(MapperClass, source)- Transform to instanceplainToInstanceArray(MapperClass, sources)- Transform arraytryPlainToInstance(MapperClass, source)- Transform with errorscreateMapper(MapperClass)- Create mapper instancegetMapper(MapperClass)- Get singleton mapper
Decorators:
@Expose(options?)- Expose property@Exclude(options?)- Exclude property@Type(typeFunction, options?)- Specify type@Transform(transformFn, options?)- Transform value@TransformClassToPlain(options?)- Method decorator@TransformClassToClass(options?)- Method decorator@TransformPlainToClass(classType, options?)- Method decorator
Functions:
plainToClass(Class, plain, options?)- Plain to classplainToInstance(Class, plain, options?)- Alias for plainToClassplainToClassFromExist(instance, plain, options?)- Update instanceclassToPlain(instance, options?)- Class to plaininstanceToPlain(instance, options?)- Alias for classToPlainclassToClass(instance, options?)- Clone instanceinstanceToInstance(instance, options?)- Alias for classToClassserialize(instance, options?)- Serialize to JSONdeserialize(Class, json, options?)- Deserialize from JSONdeserializeArray(Class, json, options?)- Deserialize array
The transformer module provides:
- ✅ 10x faster than class-transformer
- ✅ Two powerful APIs - Decorator API and Compatibility API
- ✅ Type-safe with full TypeScript support
- ✅ Zero dependencies - no reflect-metadata needed
- ✅ Easy migration - drop-in replacement for class-transformer
- ✅ Flexible - handles simple to complex transformations
- ✅ Production-ready - battle-tested and reliable
Start transforming with confidence! 🚀