All Blogs

Making AutoMapper 9 About 10x Faster

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:

Scenario8.8.1 map()9.0 map()Speedup8.8.1 mapArray() x 1k9.0 mapArray() x 1kSpeedup
classes, flat1.78 µs171 ns10.4x1.78 ms164 µs10.9x
classes, nested1.74 µs227 ns7.7x1.73 ms212 µs8.2x
pojos, flat1.85 µs181 ns10.2x1.85 ms160 µs11.6x
pojos, nested1.73 µs231 ns7.5x1.70 ms207 µs8.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/mapper is 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:

StepChangemap()CumulativeMarginal
P0baseline, before the perf commits1.79 µs1.0x
P1rewrite set() as an index walk934 ns1.9x-48%
P2remove a dead metadata scan719 ns2.5x-23%
P3lazy shouldRunImplicitMap725 ns2.5x~flat
P4cache assertUnmappedProperties keys621 ns2.9x-14%
P5memoize per-member predicates559 ns3.2x-10%
P6replace mapper Proxy with a plain object513 ns3.5x-8%
P7compile a cached per-mapping descriptor393 ns4.6x-23%
P8build the descriptor eagerly394 ns4.5x~flat
P9compile to per-property closures335 ns5.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 } object
assignEmpty(destination, base); // destination.address = {} if missing
value = 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 again
return Object.assign(obj, { [base]: value }); // allocates a { street: ... } temp object

Count 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 it
if (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 index

Same 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 null
const 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-is
const 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.

  1. mapper.map fires the get trap
  2. the trap walks an 18-branch if-else chain until it hits 'map'
  3. that branch allocates a closure every time and returns it
  4. 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.

// at createMap — once per mapping
function 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 call
const 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 transform
return (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 in
if (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 it
const setValue = ctx.setMemberFn(destinationMemberPath, ctx.destination);
setValue(value);
// after: just... pass the value
ctx.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 shape8.8.1 map()9.0 map()Speedup
wide, 40 flat fields13.9 µs1.32 µs10.5x
flat, 8 fields1.78 µs171 ns10.4x
array of objects, 50 nested items35.9 µs4.06 µs8.9x
forMember-heavy, 10 mapFrom() calls1.84 µs228 ns8.1x
deep, 4 nested levels2.40 µs331 ns7.3x
expensive transform-dominated mapping49.9 µs49.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.

Published on Thu Jul 02 2026


AutoMapper TypeScript Performance