Mapping system
AutoMap, createMap, AutoMapper, and mapping registration between classes.Koala Nest includes a mapping system inspired by AutoMapper (.NET). It converts objects between entities, requests, responses, and DTOs declaratively.
Components
| Component | Function |
|---|---|
@AutoMap() |
Marks mappable properties on a class |
createMap() |
Registers mapping between source and destination |
forMember() |
Customizes mapping of specific properties |
AutoMapper.map() |
Performs conversion between two classes |
MappingStore |
Stores mapping metadata in memory |
MappingProvider |
Registers mappings on application startup |
AutoMap decorator
export function AutoMap<T>(config?: AutoMapConfig<T>) {
return function (target: any, propertyKey: string) {
const compositionType: (() => any) | undefined = config?.type;
MappingStore.setProp(target.constructor, propertyKey, compositionType);
};
}
Usage in entity:
@OneToMany(() => PersonContact, (contact) => contact.person, {
cascade: true,
onDelete: 'CASCADE',
})
@AutoMap({ type: () => PersonContact })
contacts: PersonContact[];
Registering mappings
Create one mapper class per resource with a static createMap() method:
export class PersonMapper {
static createMap() {
createMap(Person, CreatePersonResponse);
createMap(Person, ReadPersonResponse);
createMap(PersonAddress, ReadPersonAddressResponse);
createMap(PersonContact, ReadPersonContactResponse);
createMap(CreatePersonRequest, Person);
createMap(CreatePersonAddressRequest, PersonAddress);
createMap(CreatePersonContactRequest, PersonContact);
createMap(UpdatePersonRequest, Person);
createMap(UpdatePersonAddressRequest, PersonAddress);
createMap(UpdatePersonContactRequest, PersonContact);
createMap(ReadManyPersonRequest, PersonQueryDto);
createMap(Person, ReadManyPersonResponseItem);
}
}
UpdatePersonHandler applies fields manually (contact merge). Update maps remain registered for reuse in other handlers.
Register in MappingProvider (loaded by ControllerModule):
@Injectable()
export class MappingProvider {
constructor() {
PersonMapper.createMap();
}
}
Executing mapping
const person = AutoMapper.map(
new CreatePersonValidator(req).validate(),
CreatePersonRequest,
Person,
);
return AutoMapper.map(createdPerson, Person, CreatePersonResponse);
AutoMapper resolves properties by name, maps nested objects recursively, and iterates arrays automatically.
Update with an already-loaded entity
For updates, load the entity from the database and apply the payload manually (as in UpdatePersonHandler). Persisted collections must be replaced by the request list — the expected behavior with TypeORM cascade + orphanedRowAction: 'delete': on save, items missing from the collection become orphans and are removed.
person.contacts = validated.contacts.map((contactRequest) => {
if (contactRequest.id) {
const existing = person.contacts.find((c) => c.id === contactRequest.id);
if (existing) {
existing.contact = contactRequest.contact;
return existing;
}
}
const contact = new PersonContact();
contact.contact = contactRequest.contact;
contact.person = person;
return contact;
});
Do not append to persisted collections; save treats the list as the full state.
Customizing with forMember
The forMember function allows customizing the mapping of a property. It is not used in the template's PersonMapper, but is available in src/core/tools/mapping/for-member.ts:
export function forMember<TTarget, TSource>(
targetProp: keyof TTarget,
map: (source: TSource) => TTarget[keyof TTarget],
): Partial<ForMemberResult<TTarget, TSource>> {
return {
[targetProp]: map,
} as Partial<ForMemberResult<TTarget, TSource>>;
}
Pass the result as the third argument to createMap().
Common errors
Mapping not found for {TargetClass}: the source→destination pair was not registered withcreateMap().Target properties not found for {TargetClass}: the destination class has no properties marked with@AutoMap().
Always register new mapping pairs when adding requests, responses, or entities.