In short, AutoMapper TypeScript v9 is faster, a lot faster. In the numbers I measured, upgrading from the last 8.x release (8.8.1) to 9.0 makes the common mapping path roughly 10x faster.
For the simple flat fixture:
| Scenario | 8.8.1 map() | 9.0 map() | Speedup | 8.8.1 mapArray() x 1k | 9.0 mapArray() x 1k | Speedup |
|---|---|---|---|---|---|---|
| classes, flat | 1.78 µs | 171 ns | 10.4x | 1.78 ms | 164 µs | 10.9x |
| classes, nested | 1.74 µs | 227 ns | 7.7x | 1.73 ms | 212 µs | 8.2x |
| pojos, flat | 1.85 µs | 181 ns | 10.2x | 1.85 ms | 160 µs | 11.6x |
| pojos, nested | 1.73 µs | 231 ns | 7.5x | 1.70 ms | 207 µs | 8.2x |
Not only that, memory consumption gets better too. The flat class batch went from about 12.8MB allocated per 1,000 mapped objects to about 0.87MB, roughly 15x less transient garbage.
Numbers sound really nice but the point of this blog post is to show how AI can help some old OSS codebases get better and dive into those improvements.
The improvements wouldn’t be made possible without the help of AI. I’m not going to deny that. However, the work done in
nartc/mapperis an honest collaborative work between myself and AI, along with the help of@kylecannon
Everything basically started with the following prompt when Fable 5 first dropped
/goal adversarial review the codebase and find top 10 things that can increase the performances of mapper, improve maintainabilities, while keeping the public APIs as minimally breaking as possible. Look into modern typescript ecosystem. Spawn subagents aggressively as needed.The shape of the work
The 9.0 performance work came in two phases. Phase one was nine perf(core) commits, which is the part I can decompose commit by commit. Phase two was a single follow-up PR that shipped squashed, so it lands as one extra step at the very end.
Here is phase one. Measured cumulatively against the same benchmark harness, the flat class map() path looked like this:
| Step | Change | map() | Cumulative | Marginal |
|---|---|---|---|---|
| P0 | baseline, before the perf commits | 1.79 µs | 1.0x | — |
| P1 | rewrite set() as an index walk | 934 ns | 1.9x | -48% |
| P2 | remove a dead metadata scan | 719 ns | 2.5x | -23% |
| P3 | lazy shouldRunImplicitMap | 725 ns | 2.5x | ~flat |
| P4 | cache assertUnmappedProperties keys | 621 ns | 2.9x | -14% |
| P5 | memoize per-member predicates | 559 ns | 3.2x | -10% |
| P6 | replace mapper Proxy with a plain object | 513 ns | 3.5x | -8% |
| P7 | compile a cached per-mapping descriptor | 393 ns | 4.6x | -23% |
| P8 | build the descriptor eagerly | 394 ns | 4.5x | ~flat |
| P9 | compile to per-property closures | 335 ns | 5.3x | -15% |
Writing to the destination: what set() actually did
Every property that gets mapped has to be written into the destination somewhere. AutoMapper does this through an internal set() helper that takes a path like ['address', 'street'] and writes the value at the leaf.
Here is what the old set() did for one property write:
// set(destination, ['address', 'street'], 'Main St')
// level 1: 'address'const { decomposedPath, base } = decomposePath(path); // allocates a { decomposedPath, base } objectassignEmpty(destination, base); // destination.address = {} if missingvalue = set( destination[base], decomposedPath.slice(1), // allocates a fresh ['street'] array value,);return Object.assign(destination, { [base]: value }); // allocates a { address: ... } temp object
// level 2: 'street'const { decomposedPath, base } = decomposePath(path); // allocates againreturn Object.assign(obj, { [base]: value }); // allocates a { street: ... } temp objectCount the garbage: two decomposePath result objects, one sliced array, two single-key temp objects — five throwaway allocations to write one value. The temp objects exist purely so Object.assign can merge one key into an object we already hold a reference to.
The thing is… flat case pays too. Writing ['name'] still allocated the decomposePath object and the { name: value } temp object. So an 8-field flat mapping created 16 throwaway objects per mapped object, just for the writes.
The new set() walks the path by index and writes the leaf directly:
// set(destination, ['address', 'street'], 'Main St', index = 0)
const base = path[index]; // direct index access, no slice
// leaf? write itif (index >= path.length - 1) { obj[base] = value; return object;}
if (!Object.prototype.hasOwnProperty.call(obj, base)) { obj[base] = {}; // the only object we create is one the destination actually needs}set(obj[base], path, value, index + 1); // same path array, next indexSame destination, same result. The difference is what the allocator sees along the way. The flat case is now literally obj.name = value with no temp allocations, and the nested case only creates the intermediate {} objects that end up in the destination.
This one change took the flat map() from 1.79µs to 934ns.
The check that outlived its own job
The next commit deleted 11 lines from map(). And there’s a story to tell about this one as I went down the rabbit hole to try to prove the LLMs wrong for deleting it.
Previously, here is what was running on every MapInitialize member of every map() call:
// check if metadata as destinationMemberPath is nullconst destinationMetadata = metadataMap.get(destinationIdentifier);const hasNullMetadata = destinationMetadata && destinationMetadata.find((metadata) => isPrimitiveArrayEqual( metadata[MetadataClassId.propertyKeys], destinationMemberPath, ), ) === null;Looks harmless right? Well, Array.prototype.find() returns the found element or undefined, not a null value. So hasNullMetadata has always been false. This is an O(members²) metadata scan (per member and per metadata again), per map() call.
Season 1, 2021. “Null metadata” check was real. Some members have no concrete type — an any-typed property, an arbitrary object. Their metadata says null, which means: do not try to nested-map this, just assign the value as-is. Back then this was computed once at config time and stored as a boolean in the mapping:
// 2021: decided at createMap, stored in the tuple, just read at map time[mapInitialize(sourcePath), isMetadataNullAtKey(destinationPath)];Season 2, 2022. I rewrote the internals, dropped the boolean from the tuple, and re-implemented the check as the per-call scan you see above — with the === null bug.
The bug was never noticed, not because it never came up, but because there’s another check that had quietly grown to cover it. When a member’s metadata is null on both sides, the mapping stores the pair [null, null], and the neighboring hasSameIdentifier check does this:
// null === null, and there is no registered mapping for (null, null)// → "same identifier, nothing to map it with" → assign the value as-isconst hasSameIdentifier = sameIdentifierCandidate && !getMapping(mapper, null, null, true);Which is exactly the treat-as-is behavior the 2021 boolean implemented.
Season 3, 2026. Delete the scan. Before removing it I went down the “what if find() finds an element whose value is null?” rabbit hole, and the answer is: no code path ever stores a null entry in the metadata array (nullness lives in what the entry’s metaFn() returns, one level deeper than the scan was looking), and even if one existed, the predicate would throw on null[propertyKeys] before find() could return it. I’m lucky that this never crashed.
Deleting it took another 23% off the clock and ~4 MB off the batch allocation.
The Proxy was cute
AutoMapper used to return a Proxy from createMapper().
return new Proxy({} as Mapper, { get(target, p, receiver) { if (p === STRATEGY) { /* lazy-init strategy, return it */ } if (p === MAPPINGS) { /* lazy-init mappings Map, return it */ } // ... 15 more branches ... if (p === "map") { // a brand new closure, allocated on every single access return ( sourceObject, sourceIdentifier, destinationIdentifier, options, ) => { const mapping = getMapping(receiver /* ... */); // ... actual mapping work ... }; } // ... },});Proxy can be a clean way to expose a nice API while keeping state hidden. But I don’t know what the hell I was on to use a Proxy for the Mapper, especially the mapper.map() call is on a very hot path of the entire library.
mapper.mapfires thegettrap- the trap walks an 18-branch if-else chain until it hits
'map' - that branch allocates a closure every time and returns it
- you call that closure — and inside it, reading internal state like the strategy goes through the same trap all over again
The fix is, well, a good ol’ plain object. Methods defined once, internal state hung on symbol-keyed fields:
const mapper = { map(sourceObject, sourceIdentifier, destinationIdentifier, options) { const mapping = getMapping(mapper /* ... */); // ... same mapping work as before ... }, mapArray(/* ... */) { // ... }, // ... the rest, defined once ...};
Object.defineProperties(mapper, { [MAPPINGS]: { value: mappings }, // eager, direct [STRATEGY]: { get() { // the one thing that stays lazy if (!strategy) strategy = strategyInitializer(mapper); return strategy; }, },});Now mapper.map === mapper.map is true, the property load is monomorphic, and as a small bonus, stack traces stop showing Proxy stuff.
Replacing the Proxy with a plain object only showed an 8% improvement on top of previous commits. Which is like whatever but I took the Proxy fix and put it on the main branch and the improvement was almost 3.2x win on its own. So, still a big win in my book.
The real shift: stop interpreting the map
Saving the best for last as they say as I think this is the most significant change to AutoMapper TypeScript.
AutoMapper configuration is known at createMap() time. The source model, destination model, configured members, transformation types, nested mapping decisions, and a lot of member-level metadata are not mysteries by the time map() runs. In fact, they CANNOT be mysteries by the time map() runs.
But the old hot path behaved more like an interpreter. On every object, it would walk the mapping configuration, destructure tuples, rebuild lists, check transformation types, and decide what to do for each member again and again. Imagine a mapArray() over 1,000 items, Mapper would have to do this interpretation of each member over 1,000 times.
AI raises an excellent question when it first scans nartc/mapper: why are we asking the same question per object?
Well yeah, I didn’t really have an answer as to why and only responded: If something is known at createMap() time, map() should not rediscover it.
To see how much rediscovering was going on, you have to see how a mapping is stored internally: a deeply nested tuple. A single property’s configuration looks like this:
// one entry in the mapping's properties array[ ["address", "street"], // destination path [ , // (a hole) [ transformationMapFn, // [type, fn, ...] — another tuple [preCondPredicate, preCondDefault], ], ], [destinationIdentifier, sourceIdentifier],];To my defense, at the time of writing AutoMapper TypeScript, the V8 engine wasn’t that good in terms of performance so tricks like using tuples with const enum as internal data structure was a good trick to ensure monomorphic reads.
However though, the problem was what the old map() did with it — per call, per member:
for (let i = 0; i < propsToMap.length; i++) { // re-destructure the nested tuple, with default fallbacks, every time const [ destinationMemberPath, [, [transformationMapFn, [preCond, preCondDefault = undefined] = []]], [destinationMemberIdentifier, sourceMemberIdentifier] = [], ] = propsToMap[i];
// recompute whether the identifiers are the same (invariant per mapping) let hasSameIdentifier = /* isMappableIdentifier(...) && ... */;
// allocate a fresh setMember closure for this member const setMember = (valFn) => { /* try/catch around the write */ };
// rebuild the configured-keys list (identical every call) configuredKeys.push(destinationMemberPath[0]);
// and finally: figure out what KIND of member this is... again, yeah again if (transformationMapFn[MapFnClassId.type] === TransformationType.MapInitialize) { // ... 60 lines of type checks, array checks, nested checks ... setMember(() => mapInitializedValue); // another thunk allocation } else { setMember(() => mapMember(transformationMapFn /* ... */)); // thunk + ANOTHER type switch inside }}None of these have different outputs between calls. The destructuring result is invariant. The configured keys are invariant. The transformation type is invariant. The identifier comparison is invariant.
Three commits turned this interpreter into more of a compiler.
- P7 destructures each tuple once into a flat descriptor and caches it. That took the flat path from
513nsto393nsand cut batch allocation roughly in half again. - P8 moves that build to
createMap()and hangs it on theMapping. - P9 compiles each member into a specialized step closure, once, at
createMap():
// at createMap — once per mappingfunction compileStep(prop) { // hoist everything invariant into the closure, NOW: const mapInitializeFn = /* extracted from the tuple */; const sameIdentifierCandidate = /* the identifier comparison, decided once */; const destinationIsPrimitive = isPrimitiveConstructor(prop.destinationMemberIdentifier); // the TransformationType switch happens HERE, once — // each branch returns a different specialized closure
return (ctx) => { // only per-object work survives to run time const value = mapInitializeFn(ctx.sourceObject); // ... assign it ... };}
const steps = props.map(compileStep);And map() collapses into a straight line:
// at map() — every callconst context = { sourceObject, destination, mapper /* ... */ }; // ONE object
for (let i = 0; i < steps.length; i++) { steps[i](context);}That’s it. The loop now just calls each step() closure with a context object that the steps would need. After P9, the flat class map() path landed at 335ns in the per-commit harness. That is where phase one ends: about 5.3x over the baseline.
Then @kylecannon steps in.
Phase two: the follow-up that doubled it again
To be honest, I had already called the hot path done at this point. 5.3x, compiled plan, happy. Kyle then opened a PR that took the same flat class map() from 335ns down to about 171ns — another rough doubling, on top of the one I had just spent nine commits earning. Truly humbling.
The PR shipped squashed into one commit, so I can’t slice it into a tidy waterfall the way I can for the nine. Allocation got cut again too: phase one had already taken the per-batch figure from 12.8MB down to 2.1MB, and this PR took it the rest of the way, down to about 0.87MB.
So where did the second doubling come from? Eighteen distinct optimizations by my count, but the hot-path story is three ideas.
First, mapFrom gets its own compiled step. After P9, every forMember(..., mapFrom(fn)) member still went through the generic dispatch — a thunk allocation into mapMember(), which switches on the transformation type again:
// before: the generic arm, even for the most common transformreturn (ctx) => { setMemberValue( () => mapMember(transformationMapFn, ctx.sourceObject /* ... */), // thunk + type switch inside destinationMemberPath, ctx, );};mapFrom is by far the most common custom transform, and everything about it is known at createMap() time. So it gets its own specialized step — the selector called inline, nothing allocated:
// after: compileStep recognizes MapFrom and bakes the selector inif (transformationType === TransformationType.MapFrom) { const mapFromFn = transformationMapFn[MapFnClassId.fn]; return (ctx) => { assignResolved(mapFromFn(ctx.sourceObject), destinationMemberPath, ctx); };}The same idea P9 applied to the loop, just one level deeper.
Second, the curried writer gets un-curried. The member writer was a closure factory — setMemberFn(path, destination) returned a function that you then call with the value. One extra function allocation per member write, existing purely for the calling convention:
// before: allocate a writer, then call itconst setValue = ctx.setMemberFn(destinationMemberPath, ctx.destination);setValue(value);
// after: just... pass the valuectx.setMemberFn(destinationMemberPath, ctx.destination, value);The closure survives only on the async arm, where a pending Promise genuinely needs to capture where its value goes. The sync path — the one you’re on 99% of the time — allocates nothing.
Third, the post-map check moves to config time. assertUnmappedProperties — the “did we miss anything?” check — was building a new Set(configuredKeys) and diffing it against the destination’s writable keys on every call. But which keys could possibly be unmapped is fixed the moment createMap() returns. So the residual list is precomputed once, and the per-call check becomes a walk over a usually-tiny, often-empty array. A mapArray() of 100,000 objects used to create 100,000 identical Sets to arrive at the same answer every single time.
Same rule, third appearance: if it’s knowable at createMap() time, it’s not allowed on the hot path.
The PR also went after createMap() itself. Some of that weight was phase one’s fault — an eagerly-built plan has to be built somewhere — and some of it was ancient: metadata storage re-spread the whole accumulated array per property ([...existing, item], O(P²) for a P-property class, now a push), the @AutoMap decorator had the same quadratic spread, and wide classes (over 30 properties) got a keyed metadata index instead of .find() scans. Plus a scalar fast-path so primitive values skip the Date/File tag checks entirely, and a shared options object in the mapArray() loop instead of a fresh one per element.
Does this only help toy objects?
That was the first thing I wanted to check, because the headline fixture is too simple: 8 flat fields. If a mapper only gets faster on a toy object, that is for show, but not very useful.
So I measured a few different shapes:
| Mapping shape | 8.8.1 map() | 9.0 map() | Speedup |
|---|---|---|---|
| wide, 40 flat fields | 13.9 µs | 1.32 µs | 10.5x |
| flat, 8 fields | 1.78 µs | 171 ns | 10.4x |
| array of objects, 50 nested items | 35.9 µs | 4.06 µs | 8.9x |
forMember-heavy, 10 mapFrom() calls | 1.84 µs | 228 ns | 8.1x |
| deep, 4 nested levels | 2.40 µs | 331 ns | 7.3x |
| expensive transform-dominated mapping | 49.9 µs | 49.5 µs | ~1.0x |
The engine-bound mappings get roughly 7-11x faster. Wide mappings benefit the most because the old per-property overhead scaled with field count, so removing it pays off once per field. Deep mappings and arrays still improve, but not magically more than flat ones, because their extra work is actual nested mapping work.
And if your mapping is dominated by an expensive mapFrom() function, the engine improvement basically disappears. If you spend 98% of your time inside your own transformation function, making the mapper engine faster can only help the remaining 2%. I would rather admit that than pretend every user will see 10x everywhere.
A correctness note about async
One 9.0 change is not a performance win at all.
The old mapAsync() path did not actually await async member resolvers. If a mapFrom() returned a Promise, the destination could receive the unresolved Promise instead of the resolved value. That made the old version look very fast in one specific way because it returned before the async work finished.
In 9.0, mapAsync() waits for async member work and assigns the settled value. For mapArrayAsync(), the mapper collects the pending work and awaits it concurrently with Promise.all(), so 50 objects with a 20ms async resolver do not become 50 x 20ms sequential work.
There is a migration caveat here. If some code accidentally relied on the old behavior, it will observe different timing and different output. But the old output was a leaked Promise, so I am comfortable calling this a correctness fix instead of a perf regression.
How I measured it
The numbers came from built artifacts, not TypeScript source running directly in a benchmark setup. The 8.8.1 baseline was the published package. The 9.0 numbers used the built ESM output a user would actually install.
For the per-commit waterfall, I held the 9.0 build toolchain constant and replayed each core source snapshot cumulatively. That way, the toolchain does not get to take credit for an algorithm change.
The machine was an Apple M5 Max on Node 22.14, using mitata, reporting medians from three runs. Micro-benchmarks are still micro-benchmarks. Real applications have I/O, cold starts, surrounding allocations, and plenty of other noise. So, the absolute numbers will vary.
The rule of thumb
The practical takeaway for me is pretty simple:
If the mapper knows something at createMap() time, map() should not rediscover it per object.
Thanks for reading, and have fun!
Benchmark notes: phase one measured 2026-06-21; headline and scenario numbers re-baselined 2026-06-23 against the current 9.0 tip. Apple M5 Max, Node 22.14. Phase-one per-commit references: fc11170, c313fa8, 21a0f28, 72edd19, 2bfef38, 49bfbf0, cb772ac, 038d14d, dabf047. Phase two: PR #632.