Mapper

Schema-to-schema object mapping with compile-time completeness checking, auto-mapping, and zero decorators.

Installation

npm install @cleverbrush/mapper @cleverbrush/schema

Why use a mapper?

In 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.

Basic usage

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 }

Mapping strategies

StrategySyntaxPurpose
.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

Auto-mapping

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
);

Nested & array schemas

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 mapper

Compile-time completeness

If 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.