@cleverbrush/mapper

Type-safe object mapping between schemas with compile-time completeness checking.

$npm install @cleverbrush/mapper

Requires @cleverbrush/schema as a peer dependency.

💡 Why @cleverbrush/mapper?

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.

Three Mapping Strategies

  • .from() — copy a value directly from a source property (with nested path support)
  • .compute() — transform or derive a value from the entire source object
  • .ignore() — explicitly exclude a property (tells the compiler you intentionally skipped it)

Auto-Mapping

Properties with the same name and compatible type are automatically mapped — no configuration needed. Registered nested schema mappings are applied recursively. You only write mappings for properties that differ between source and target.

Immutable Registry

The configure() method returns a new registry, so you can build up mappers incrementally without mutation. This makes it safe to share a base registry across modules and extend it per-context.

Production Tested

Every API response in cleverbrush.com/editor is mapped through @cleverbrush/mapper registries. It handles dozens of schema-to-schema mappings in production every day, including deeply nested objects and computed properties.

How It Works — Step by Step

  1. Define source and target schemas using @cleverbrush/schema — these describe the input and output shapes
  2. Create a registry with mapper() — this is where all your mappings live
  3. Configure mappings with .configure(from, to, fn) — use .for() to pick a target property, then .from(), .compute(), or .ignore()to define how it's populated
  4. Auto-mapping fills the gaps — properties with the same name and compatible type are mapped automatically; you only configure what differs
  5. Get a mapper function with registry.getMapper(from, to) — returns an async function that transforms objects
  6. TypeScript enforces completeness — if any target property is unmapped, you get a compile-time type error (a type-assignability mismatch that includes the unmapped property names in the type parameters)

Quick Start

Define source and target schemas, configure a mapping, and transform objects:

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

// 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
const mapFn = registry.getMapper(ApiUser, DomainUser);

// Map an object
const user = await mapFn({
  first_name: 'Jane',
  last_name:  'Doe',
  birth_year: 1995
});
// { fullName: 'Jane Doe', age: <current year - 1995> }

Compile-Time Safety

One of the most powerful features of @cleverbrush/mapper is that the TypeScript compiler catches mapping mistakes before your code runs. Here are three examples:

1. Unmapped Properties Error

If you forget to map a property, calling .getMapper() produces a compile-time error:

const registry = mapper()
  .configure(ApiUser, DomainUser, (m) =>
    m
      .for((t) => t.fullName)
        .compute((src) => src.first_name + ' ' + src.last_name)
      // Oops — forgot to map 'age'!
      .getMapper()
      // TS Error: Property 'getMapper' does not exist on type
      // Mapper<..., "age", ...>
      // The type tracks unmapped properties and only exposes
      // getMapper() when all properties are accounted for.
  );

2. Type-Incompatible Mapping Error

If you use .from()to copy a property but the types don't match, TypeScript catches it:

// Trying to map a string source to a number target
m.for((t) => t.age)
  .from((src) => src.first_name)
  // TS Error: string is not assignable to number
  // Use .compute() instead to transform the value

3. Unregistered Nested Mapping Error

If your target has a nested object schema and no mapping is registered for it, you get a compile-time error when the inner schema cannot be auto-mapped:

const Source = object({ address: SourceAddress });
const Target = object({ address: TargetAddress });

// If SourceAddress -> TargetAddress mapping is not registered,
// the compiler will flag the unmapped nested properties.
// Register it first, then the parent mapping will auto-apply it.

Auto-Mapping

When source and target schemas share properties with the same name and compatible types, those properties are mapped automatically. You only need to configure properties that differ:

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

For nested object schemas, if a mapping between the nested source and target schemas is registered in the same registry, it will be applied recursively:

const SourceAddr = object({ line1: string(), line2: string() });
const TargetAddr = object({ fullAddress: string() });

const Source = object({ name: string(), address: SourceAddr });
const Target = object({ name: string(), address: TargetAddr });

// Register the nested mapping first
const registry = mapper()
  .configure(SourceAddr, TargetAddr, (m) =>
    m.for((t) => t.fullAddress)
      .compute((src) => src.line1 + ', ' + src.line2)
  )
  // Now the parent mapping auto-applies the nested one
  .configure(Source, Target, (m) =>
    m // name is auto-mapped, address uses the registered nested mapping
  );

Mapping Strategies

StrategyUsageDescription
.from(selector).for(t => t.name).from(s => s.name)Copy a value directly from a source property. Types must be compatible. Supports nested paths.
.compute(fn).for(t => t.fullName).compute(s => s.first + ' ' + s.last)Compute the target value from the entire source object. Can be sync or async. Use for transformations, concatenation, lookups, etc.
.ignore().for(t => t.internal).ignore()Explicitly exclude a target property. The property will be undefined in the output. Tells the compiler you intentionally skipped it.
Auto-mapped(implicit)Same-name, same-type properties are automatically copied. Registered nested schema mappings are applied recursively.

Comparison with Alternatives

Feature@cleverbrush/mapperAutoMapper-tsclass-transformermorphism
Schema-driven mapping
Compile-time completeness
No decorators required
Works without classes
Central registry
Custom transforms~
Auto-mapping
Type-safe selectors
Immutable registry
Nested schema support~~

API Reference

Class / MethodDescriptionSignature
mapper()Factory function that creates a new MappingRegistry() => MappingRegistry
MappingRegistryCentral registry holding all schema-to-schema mappingsmapper()
.configure(from, to, fn)Register a mapping between two schemas. Returns a new registry (immutable).(from, to, fn) => MappingRegistry
.getMapper(from, to)Retrieve the mapping function for a registered pair of schemas.(from, to) => (obj) => Promise<T>
Mapper.for(selector)Select a target property to configure (returns PropertyMappingBuilder).(t => t.prop) => PropertyMappingBuilder
.from(selector)Copy value from a source property. Types must be compatible.(s => s.prop) => Mapper
.compute(fn)Compute target value from source object. Sync or async.(src => value) => Mapper
.ignore()Explicitly exclude this target property.() => Mapper
.getMapper()Get mapping function from a fully configured Mapper. Only available when all properties are mapped.() => (obj) => Promise<T>
MapperConfigurationErrorThrown at runtime when a mapping configuration is incomplete or invalid.extends Error

Real-World Example

A complete example mapping API responses through multiple layers — a common pattern in production apps:

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

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

// Domain model (used in the app)
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' }