Type-safe object mapping between schemas with compile-time completeness checking.
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.
@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.
.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)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.
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.
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.
@cleverbrush/schema — these describe the input and output shapesmapper() — this is where all your mappings live.configure(from, to, fn) — use .for() to pick a target property, then .from(), .compute(), or .ignore()to define how it's populatedregistry.getMapper(from, to) — returns an async function that transforms objectsDefine 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> }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:
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.
);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 valueIf 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.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
);| Strategy | Usage | Description |
|---|---|---|
.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. |
| Feature | @cleverbrush/mapper | AutoMapper-ts | class-transformer | morphism |
|---|---|---|---|---|
| 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 | ✓ | ~ | ~ | ✗ |
| Class / Method | Description | Signature |
|---|---|---|
mapper() | Factory function that creates a new MappingRegistry | () => MappingRegistry |
MappingRegistry | Central registry holding all schema-to-schema mappings | mapper() |
.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> |
MapperConfigurationError | Thrown at runtime when a mapping configuration is incomplete or invalid. | extends Error |
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' }