Libraries
    Preparing search index...

    Module @cleverbrush/mapper

    @cleverbrush/mapper

    CI License: BSD-3-Clause

    Coverage

    A type-safe, declarative object mapper for converting objects between different @cleverbrush/schema representations. Uses PropertyDescriptors as pointers to properties (similar to expressions in C# .NET) and enforces compile-time completeness — TypeScript will produce an error if any target property is not mapped, auto-mapped, or explicitly ignored.

    The problem: Converting between different object shapes — API responses to domain models, domain models to DTOs, database rows to view models — is tedious and error-prone. You write manual mapping functions full of destination.x = source.y assignments. Add a new property to a schema and nothing tells you the mapper is incomplete. The bug shows up at runtime, not at compile time.

    The solution: @cleverbrush/mapper uses PropertyDescriptor-based selectors (similar to C# expression trees) for type-safe property mapping. The TypeScript compiler enforces that every target property is mapped — unmapped properties cause a compile-time error. You literally cannot forget a field.

    What makes it different:

    • Compile-time completeness — unmapped properties are a TypeScript error, not a runtime surprise
    • Type-safe selectors.for((t) => t.name).from((s) => s.name) — fully checked at compile time, not string-based
    • Auto-mapping — properties with the same name and compatible type are mapped automatically; you only configure what differs
    • Immutable registryconfigure() returns a new registry; safe to share and extend
    • No decorators or classes — works with plain objects and schemas
    Feature @cleverbrush/mapper AutoMapper-ts class-transformer morphism
    Compile-time completeness
    Type-safe selectors
    No decorators required
    Works without classes
    Auto-mapping
    Immutable registry
    Nested schema support ~ ~
    npm install @cleverbrush/mapper
    

    Peer dependency: @cleverbrush/schema

    import { object, string, number } from '@cleverbrush/schema';
    import { mapper } from '@cleverbrush/mapper';

    // Define source and target schemas
    const ApiUser = object({
    first_name: string(),
    last_name: string(),
    birth_year: number()
    });

    const DomainUser = object({
    fullName: string(),
    age: number()
    });

    // Configure the mapping — returns a new (immutable) registry
    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)
    );

    // Get the mapper function and use it
    const mapFn = registry.getMapper(ApiUser, DomainUser);

    const dto = await mapFn({
    first_name: 'Jane',
    last_name: 'Doe',
    birth_year: 1995
    });
    // { fullName: 'Jane Doe', age: <current year - 1995> }
    1. Define schemas — use @cleverbrush/schema to define source and target shapes
    2. Configure mappings — use .for() to select a target property, then .from(), .compute(), or .ignore() to define how it's populated
    3. Auto-mapping fills the gaps — properties with the same name and compatible type are mapped automatically
    4. Get a mapper functionregistry.getMapper(from, to) returns an async function that transforms objects
    5. TypeScript enforces completeness — if any target property is unmapped, you get a compile-time error

    The mapper enforces multiple layers of compile-time safety:

    Every target property must be either mapped, auto-mapped, or explicitly ignored. If you forget to map a property, TypeScript will produce a compile-time error on the configure callback return:

    mapper().configure(
    UserSchema,
    UserDtoSchema,
    (m) =>
    m
    .for((t) => t.name)
    .from((f) => f.name)
    .for((t) => t.cityName)
    .from((f) => f.address.city)
    // TS Error: Type 'Mapper<..., "fullAddress", ...>' is not assignable to
    // type 'Mapper<..., never, ...>'.
    // Types of property '[SYMBOL_UNMAPPED]' are incompatible.
    // Type '"fullAddress"' is not assignable to type 'never'.
    );

    The error message shows the names of the unmapped properties directly in the type mismatch.

    from only shows source properties whose InferType is assignable to the target property's type. If you select an incompatible property (e.g., mapping a number to a string), TypeScript produces a compile-time error:

    // Trying to map a string target from a number source
    m.for((t) => t.cityName).from((f) => f.houseNr)
    // TS Error: source property type is not assignable to target property type
    // Use .compute() instead to transform the value

    When from maps between two ObjectSchemaBuilder properties, a mapping for that schema pair must be registered in the registry first. Otherwise, TypeScript produces a compile-time error:

    const PersonSchema = object({ name: string(), address: AddressSchema });
    const PersonDtoSchema = object({ name: string(), address: AddressDtoSchema });

    // Error — AddressSchema→AddressDtoSchema is not registered
    mapper().configure(
    PersonSchema,
    PersonDtoSchema,
    (m) =>
    m
    .for((t) => t.name)
    .from((f) => f.name)
    .for((t) => t.address)
    .from((f) => f.address) // TS Error: Register a mapping for the
    // source→target schema pair first
    );

    Properties that can be automatically determined don't need explicit mapping configuration. Auto-mapping activates in two scenarios:

    When the source and target schemas have a property with the same name and compatible InferType, it is auto-mapped automatically:

    const Source = object({
    id: string(),
    name: string(),
    email: string(),
    age: number()
    });

    const Target = object({
    id: string(), // same name + type → auto-mapped
    name: string(), // same name + type → auto-mapped
    email: string(), // same name + type → auto-mapped
    ageGroup: string() // different name → must be configured
    });

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

    When both the source and target have a same-name property that is an ObjectSchemaBuilder, and a mapping for that schema pair has been previously registered, the nested property is auto-mapped using the registered mapper:

    const AddressSchema = object({ city: string(), houseNr: number() });
    const AddressDtoSchema = object({ city: string() });

    const PersonSchema = object({ name: string(), address: AddressSchema });
    const PersonDtoSchema = object({ name: string(), address: AddressDtoSchema });

    const registry = mapper()
    // Register Address mapping first
    .configure(AddressSchema, AddressDtoSchema, (m) =>
    m.for((t) => t.city).from((f) => f.city)
    )
    // address is auto-mapped using the registered AddressSchema→AddressDtoSchema mapper
    .configure(PersonSchema, PersonDtoSchema, (m) =>
    m.for((t) => t.name).from((f) => f.name)
    );

    const mapFn = registry.getMapper(PersonSchema, PersonDtoSchema);
    const result = await mapFn({
    name: 'Alice',
    address: { city: 'Berlin', houseNr: 10 }
    });
    // result: { name: 'Alice', address: { city: 'Berlin' } }

    Ordering matters: nested mappings must be registered before the parent mapping. Explicit mappings via compute or ignore take priority over auto-mapping.

    Strategy Usage Purpose
    .from(selector) .for(t => t.x).from(s => s.y) Copy from a source property (supports nested paths)
    .compute(fn) .for(t => t.x).compute(s => s.a + s.b) Compute from a sync or async function
    .ignore() .for(t => t.x).ignore() Exclude a target property
    (auto-mapped) (no configuration needed) Same-name, compatible-type primitives or registered nested schemas

    Every non-auto-mappable target property must be either mapped or explicitly ignored. Unmapped properties cause:

    • A compile-time TypeScript type error — a type-assignability mismatch that includes the unmapped property names in the type parameters
    • A runtime MapperConfigurationError if type checks are bypassed

    A convenience factory function that creates a new MappingRegistry:

    const registry = mapper()
    .configure(A, B, (m) => ...)
    .configure(C, D, (m) => ...);

    Defines a mapping between two schemas and returns a new immutable registry containing the mapping. The callback fn receives a fresh Mapper and must return it after configuring all non-auto-mappable property mappings.

    Throws if schemas are invalid, the mapping is a duplicate, or if unmapped properties remain that cannot be auto-mapped.

    Retrieves a previously registered mapper function. Throws if no mapper has been registered for the given schema pair.

    const mapFn = registry.getMapper(ApiUser, DomainUser);
    const result = await mapFn(sourceObject);

    A fluent builder for configuring how each target property is populated:

    • .for(selector) — selects a target property to configure
    • .from(selector) — maps from a source property (types must be compatible)
    • .compute(fn) — computes the value from the entire source object (sync or async)
    • .ignore() — explicitly excludes the property
    • .getMapper() — returns the mapping function (only available when all properties are mapped)
    const mapper = new Mapper(SourceSchema, TargetSchema);
    const mapFn = mapper
    .for((t) => t.fullName)
    .compute((src) => `${src.firstName} ${src.lastName}`)
    .for((t) => t.age)
    .from((src) => src.years)
    .getMapper();

    const result = await mapFn(sourceObject);

    A complete example mapping API responses through multiple layers:

    import { object, string, number } from '@cleverbrush/schema';
    import { mapper } from '@cleverbrush/mapper';

    // API response shape
    const ApiOrderResponse = object({
    order_id: string(),
    customer_name: string(),
    total_cents: number(),
    status_code: number()
    });

    // Domain model
    const Order = object({
    id: string(),
    customer: string(),
    totalPrice: string(),
    status: string()
    });

    const registry = mapper().configure(
    ApiOrderResponse,
    Order,
    (m) =>
    m
    .for((t) => t.id)
    .from((s) => s.order_id)
    .for((t) => t.customer)
    .from((s) => s.customer_name)
    .for((t) => t.totalPrice)
    .compute((s) => `$${(s.total_cents / 100).toFixed(2)}`)
    .for((t) => t.status)
    .compute((s) => {
    const statuses: Record<number, string> = {
    0: 'pending', 1: 'confirmed', 2: 'shipped', 3: 'delivered'
    };
    return statuses[s.status_code] ?? 'unknown';
    })
    );

    const mapOrder = registry.getMapper(ApiOrderResponse, Order);
    const order = await mapOrder({
    order_id: 'ORD-123',
    customer_name: 'Alice Smith',
    total_cents: 4999,
    status_code: 2
    });
    // { id: 'ORD-123', customer: 'Alice Smith', totalPrice: '$49.99', status: 'shipped' }
    • Linting: Biome — enforced on every PR via CI
    • Type checking: TypeScript strict mode — all type selectors and mapping configurations are validated at compile time
    • Unit tests: Vitest — runtime tests + type-level tests (expectTypeOf) covering auto-mapping, computed fields, nested schemas, and compile-time completeness errors
    • CI: Every pull request must pass lint + build + test before merge — see .github/workflows/ci.yml

    BSD-3-Clause

    Classes

    Mapper
    MapperConfigurationError
    MappingRegistry
    PropertyMappingBuilder

    Type Aliases

    SchemaToSchemaMapperResult

    Functions

    mapper