Schema-to-schema object mapping with compile-time completeness checking, auto-mapping, and zero decorators.
npm install @cleverbrush/mapper @cleverbrush/schemaIn non-trivial apps, the shape of data differs between layers — API DTOs, domain models, database rows, view models. Manual mapping code is repetitive and silently breaks when you add a new field.
@cleverbrush/mapper uses your existing @cleverbrush/schema definitions to generate mapping functions — and gives you a compile-time error if any target property is unmapped.
import { object, string, number } from '@cleverbrush/schema';
import { mapper } from '@cleverbrush/mapper';
const ApiUser = object({
first_name: string(),
last_name: string(),
birth_year: number()
});
const DomainUser = object({
fullName: string(),
age: number()
});
const registry = mapper().configure(ApiUser, DomainUser, (m) =>
m
.for((t) => t.fullName)
.compute((src) => src.first_name + ' ' + src.last_name)
.for((t) => t.age)
.compute((src) => new Date().getFullYear() - src.birth_year)
);
const mapFn = registry.getMapper(ApiUser, DomainUser);
const result = await mapFn({
first_name: 'Jane',
last_name: 'Doe',
birth_year: 1995
});
// { fullName: 'Jane Doe', age: 30 }| Strategy | Syntax | Purpose |
|---|---|---|
.from() | .for(t => t.x).from(s => s.y) | Copy from a source property |
.compute() | .for(t => t.x).compute(s => ...) | Compute from a sync or async function |
.ignore() | .for(t => t.x).ignore() | Exclude property from output |
| Auto-mapped | (no config needed) | Same-name compatible primitives, or registered nested schemas |
Properties with the same name and compatible types are mapped automatically — you only need to configure the differences:
const Source = object({
id: string(),
name: string(),
email: string(),
age: number()
});
const Target = object({
id: string(),
name: string(),
email: string(),
ageGroup: string()
});
const registry = mapper().configure(Source, Target, (m) =>
m
.for((t) => t.ageGroup)
.compute((src) => (src.age < 18 ? 'minor' : 'adult'))
// id, name, email are auto-mapped — no configuration needed
);Register the child mapping first, and the parent mapper automatically uses it for nested objects and array elements:
const Address = object({ city: string(), houseNr: number() });
const AddressDto = object({ city: string() });
const Person = object({ name: string(), address: Address });
const PersonDto = object({ name: string(), address: AddressDto });
const registry = mapper()
.configure(Address, AddressDto, (m) =>
m.for((t) => t.city).from((f) => f.city)
)
.configure(Person, PersonDto, (m) =>
m.for((t) => t.name).from((f) => f.name)
);
// address is auto-mapped using the registered Address→AddressDto mapperIf you add a new property to the target schema and forget to map it, TypeScript reports an error at build time — not at runtime:
// Target gains a new 'role' property
const Target = object({ name: string(), role: string() });
// ❌ TypeScript error:
// 'role' is unmapped — SYMBOL_UNMAPPED prevents compilation
const registry = mapper().configure(Source, Target, (m) =>
m.for((t) => t.name).from((s) => s.name)
);The registry is immutable — configure() returns a new registry, safe to share and extend.