Implement deepMap(value, transform) — walk an arbitrarily nested structure (arrays and plain objects) and apply transform to every leaf value, returning a new structure with the same shape but with each leaf replaced by transform(leaf). A leaf is anything that is not a plain object or array: numbers, strings, booleans, null, undefined, Date, functions, class instances — they all reach transform untouched. Think of it as Array.prototype.map generalised across heterogeneous trees instead of a flat list.
// Returns a NEW value with the same shape as `value`, where every leaf
// has been replaced by transform(leaf). Containers (arrays and plain
// objects) are recreated fresh; nothing in the result aliases the input.
function deepMap<T>(value: T, transform: (leaf: unknown) => unknown): T;
// Flat object — every value goes through transform.
deepMap({ a: 1, b: 2, c: 3 }, (x) => x * 10);
// → { a: 10, b: 20, c: 30 }
// Nested object — the recursion descends; only the LEAVES are mapped.
deepMap({ user: { name: 'ada', age: 30 } }, (x) =>
typeof x === 'string' ? x.toUpperCase() : x,
);
// → { user: { name: 'ADA', age: 30 } }
// Array of primitives.
deepMap([1, 2, 3, 4], (x) => x * x);
// → [1, 4, 9, 16]
// Mixed — object containing arrays containing objects.
deepMap(
{ items: [{ qty: 1 }, { qty: 2 }], total: 3 },
(x) => (typeof x === 'number' ? x + 100 : x),
);
// → { items: [{ qty: 101 }, { qty: 102 }], total: 103 }
Array.isArray) or a plain object literal. Everything else — primitives, Date, Map, functions, class instances — is a leaf. transform is called on the leaf as a whole; you never recurse into a Date.null is a leaf. typeof null === 'object', but null has no properties — pass it to transform rather than treating it like a container.Date and other class instances are leaves. Even though typeof new Date() === 'object', you do not recurse into its internals; transform receives the Date itself.WeakMap-based extension.You'll build a recursive function that walks an arbitrary tree of arrays and plain objects, calls transform on every leaf, and returns a brand-new tree with the same shape.
You have a config blob, a tree of nested objects and arrays, and you want to do something to every "real value" in it — uppercase every string, round every number, replace every null. The data has nested structure though, so you can't just Object.values(...).map(...) — there are values inside values inside arrays inside objects. You want Array.prototype.map, but for a heterogeneous tree instead of a flat list. Recursion is the natural fit.
Look at the input as a tree. Two kinds of nodes live in it. Containers — arrays and plain object literals — have children; you don't transform them, you walk into them and rebuild them around their transformed children. Leaves — numbers, strings, booleans, null, undefined, Date, anything that isn't a container — are the actual values you call transform on. The function visits every leaf exactly once and every container exactly once.
The recursion is two halves. On the way down, you split a container into its children and call yourself on each child. On the way back up, you collect the returned values into a fresh container and return it.
The first version everyone writes ignores the recursion and just maps the top level:
function deepMapNaive(obj, transform) {
const result = {};
for (const k in obj) {
result[k] = transform(obj[k]);
}
return result;
}
Run it on a one-level-deep input and it falls over immediately:
deepMapNaive({ a: { b: 1 } }, (x) => x * 2);
// → { a: NaN }
// ^^^ transform({ b: 1 }) = { b: 1 } * 2 = NaN
We passed an object to transform. Multiplying an object by 2 is NaN. If transform happened to be String, we'd get '[object Object]' — same shape of bug, different garbage. The naive version doesn't know the difference between "this is a leaf, transform it" and "this is a container, look inside" — it treats every property of the top-level object as a leaf, even when it's another whole subtree.
It also doesn't handle arrays at all (for...in on [10, 20] works by accident but loses length), doesn't handle a bare primitive input (deepMapNaive(42, ...) returns {}), and mutates nothing — but it also produces nothing useful for a tree with more than one level.
The fix is the missing concept: at each value, ask "is this a container or a leaf?" before calling transform.
// "Plain object" means a literal `{}` or `Object.create(null)` — not an
// array, Date, Map, RegExp, or class instance. Those are leaves: we don't
// know what's inside them and we shouldn't recurse on their internals.
function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
function deepMap(value, transform) {
// Single recursive worker — closes over `transform` so we don't pass it
// through every call. The closure also means the public signature stays
// (value, transform) instead of leaking a recursion-helper parameter.
function recurse(v) {
// Arrays: allocate a new array of the SAME length by mapping each
// element through recurse. `Array.prototype.map` already returns a
// brand-new array, so we get fresh-reference-at-every-level for free.
if (Array.isArray(v)) {
return v.map(recurse);
}
// Plain objects: allocate a new {}, then walk own keys and recurse.
// `Object.keys` skips the prototype chain and non-enumerable keys —
// both the right call here (we don't want to copy inherited junk).
if (isPlainObject(v)) {
const out = {};
for (const k of Object.keys(v)) {
out[k] = recurse(v[k]);
}
return out;
}
// Everything else is a leaf: primitives, null, undefined, Date, Map,
// functions, class instances. `transform` decides what to do with it.
return transform(v);
}
return recurse(value);
}
module.exports = { deepMap };
Three shifts from the naive version. First, the function asks the right question at each step. Array.isArray and isPlainObject together split the universe into "container, recurse" and "leaf, transform" — no value falls through both branches. Second, the recursion is the whole algorithm. Each container's job is to recurse on its children and assemble the results; the base case is a leaf, where transform finally runs. Third, every container is freshly allocated (v.map(...) for arrays, const out = {} for objects), so the result shares no references with the input — mutating the result can never reach back to mutate value.
Trace deepMap({ a: 1, b: [2, { c: 3 }] }, x => x * 10).
recurse({ a: 1, b: [2, { c: 3 }] }). Not an array. isPlainObject is true (prototype is Object.prototype). Allocate out = {}. Iterate keys ['a', 'b'].'a': recurse(1). Not an array, not a plain object. Leaf. Call transform(1) → 10. Return 10. Outer call sets out.a = 10.'b': recurse([2, { c: 3 }]). Array.isArray is true. Call .map(recurse) over the two elements:
0: recurse(2). Leaf. transform(2) = 20. Returns 20.1: recurse({ c: 3 }). Plain object. Allocate inner out2 = {}. Iterate keys ['c'].
'c': recurse(3). Leaf. transform(3) = 30. Returns 30. Inner call sets out2.c = 30.{ c: 30 }..map collects [20, { c: 30 }] and returns it as a brand-new array.out.b = [20, { c: 30 }].out = { a: 10, b: [20, { c: 30 }] }.Each leaf went through transform exactly once. Each container is a fresh allocation — out, the inner array [20, {c: 30}], and the inner object { c: 30 } are all new objects. value, value.b, and value.b[1] were never written to.
typeof === 'object' is not a container check. Arrays, Date, Map, Set, RegExp, class instances, and null all return 'object'. If you write if (typeof v === 'object') { /* recurse */ }, you'll happily recurse into a Date's enumerable properties (it has none, so you get {} back — a silent corruption) and crash on Object.keys(null). Use Array.isArray plus a real isPlainObject check, in that order.new MyClass() is typeof 'object', prototype is MyClass.prototype — not Object.prototype. If you let class instances into the recursion you'll lose the instance and return a bag of its enumerable fields, which won't respond to its methods anymore. The proto === Object.prototype || proto === null test is what keeps Object.create(null) and {} in, and keeps new Date() / new MyClass() out.null is a leaf, not a container. typeof null === 'object' — the most famous JS gotcha. Without a value === null guard up front in isPlainObject, the function falls through to Object.getPrototypeOf(null) which throws. Once you guard, null cleanly drops out as a leaf and gets passed to transform.const a = {}; a.self = a; deepMap(a, x => x) and the recursion never bottoms out — RangeError: Maximum call stack size exceeded. A WeakMap keyed by source object, populated before the children are walked, fixes it; see Going further.undefined. [, , 3] has length 3 and Array.isArray true, but Object.keys returns ['2'] — the holes are skipped. Array.prototype.map also skips holes, so the holes survive into the result without going through transform. An explicit [undefined, undefined, 3] does call transform on each undefined. If you care about treating holes and undefined the same, swap v.map(recurse) for an explicit for (let i = 0; i < v.length; i++) loop that doesn't check i in v.transform(leaf, path) where path is an array like ['user', 'tags', 0]. Thread path through the recursive helper — append key going in, no extra state needed. Useful for "uppercase only user.name, not every string."WeakMap. Keep a WeakMap of sourceObject -> resultContainer. Before recursing into a container, set the entry; on each entry to recurse, check the map first and return the existing result if found. Same pattern as deepClone. The tradeoff: a small constant overhead per call, in exchange for never crashing on a cyclic input.Object.keys ignores symbol-keyed properties. If your inputs use symbols (rare in plain data, common in framework-tagged objects), swap in Reflect.ownKeys to walk both string and symbol keys, and decide whether non-enumerable own properties should ride along too.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement deepMap(value, transform) — walk an arbitrarily nested structure (arrays and plain objects) and apply transform to every leaf value, returning a new structure with the same shape but with each leaf replaced by transform(leaf). A leaf is anything that is not a plain object or array: numbers, strings, booleans, null, undefined, Date, functions, class instances — they all reach transform untouched. Think of it as Array.prototype.map generalised across heterogeneous trees instead of a flat list.
// Returns a NEW value with the same shape as `value`, where every leaf
// has been replaced by transform(leaf). Containers (arrays and plain
// objects) are recreated fresh; nothing in the result aliases the input.
function deepMap<T>(value: T, transform: (leaf: unknown) => unknown): T;
// Flat object — every value goes through transform.
deepMap({ a: 1, b: 2, c: 3 }, (x) => x * 10);
// → { a: 10, b: 20, c: 30 }
// Nested object — the recursion descends; only the LEAVES are mapped.
deepMap({ user: { name: 'ada', age: 30 } }, (x) =>
typeof x === 'string' ? x.toUpperCase() : x,
);
// → { user: { name: 'ADA', age: 30 } }
// Array of primitives.
deepMap([1, 2, 3, 4], (x) => x * x);
// → [1, 4, 9, 16]
// Mixed — object containing arrays containing objects.
deepMap(
{ items: [{ qty: 1 }, { qty: 2 }], total: 3 },
(x) => (typeof x === 'number' ? x + 100 : x),
);
// → { items: [{ qty: 101 }, { qty: 102 }], total: 103 }
Array.isArray) or a plain object literal. Everything else — primitives, Date, Map, functions, class instances — is a leaf. transform is called on the leaf as a whole; you never recurse into a Date.null is a leaf. typeof null === 'object', but null has no properties — pass it to transform rather than treating it like a container.Date and other class instances are leaves. Even though typeof new Date() === 'object', you do not recurse into its internals; transform receives the Date itself.WeakMap-based extension.You'll build a recursive function that walks an arbitrary tree of arrays and plain objects, calls transform on every leaf, and returns a brand-new tree with the same shape.
You have a config blob, a tree of nested objects and arrays, and you want to do something to every "real value" in it — uppercase every string, round every number, replace every null. The data has nested structure though, so you can't just Object.values(...).map(...) — there are values inside values inside arrays inside objects. You want Array.prototype.map, but for a heterogeneous tree instead of a flat list. Recursion is the natural fit.
Look at the input as a tree. Two kinds of nodes live in it. Containers — arrays and plain object literals — have children; you don't transform them, you walk into them and rebuild them around their transformed children. Leaves — numbers, strings, booleans, null, undefined, Date, anything that isn't a container — are the actual values you call transform on. The function visits every leaf exactly once and every container exactly once.
The recursion is two halves. On the way down, you split a container into its children and call yourself on each child. On the way back up, you collect the returned values into a fresh container and return it.
The first version everyone writes ignores the recursion and just maps the top level:
function deepMapNaive(obj, transform) {
const result = {};
for (const k in obj) {
result[k] = transform(obj[k]);
}
return result;
}
Run it on a one-level-deep input and it falls over immediately:
deepMapNaive({ a: { b: 1 } }, (x) => x * 2);
// → { a: NaN }
// ^^^ transform({ b: 1 }) = { b: 1 } * 2 = NaN
We passed an object to transform. Multiplying an object by 2 is NaN. If transform happened to be String, we'd get '[object Object]' — same shape of bug, different garbage. The naive version doesn't know the difference between "this is a leaf, transform it" and "this is a container, look inside" — it treats every property of the top-level object as a leaf, even when it's another whole subtree.
It also doesn't handle arrays at all (for...in on [10, 20] works by accident but loses length), doesn't handle a bare primitive input (deepMapNaive(42, ...) returns {}), and mutates nothing — but it also produces nothing useful for a tree with more than one level.
The fix is the missing concept: at each value, ask "is this a container or a leaf?" before calling transform.
// "Plain object" means a literal `{}` or `Object.create(null)` — not an
// array, Date, Map, RegExp, or class instance. Those are leaves: we don't
// know what's inside them and we shouldn't recurse on their internals.
function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
function deepMap(value, transform) {
// Single recursive worker — closes over `transform` so we don't pass it
// through every call. The closure also means the public signature stays
// (value, transform) instead of leaking a recursion-helper parameter.
function recurse(v) {
// Arrays: allocate a new array of the SAME length by mapping each
// element through recurse. `Array.prototype.map` already returns a
// brand-new array, so we get fresh-reference-at-every-level for free.
if (Array.isArray(v)) {
return v.map(recurse);
}
// Plain objects: allocate a new {}, then walk own keys and recurse.
// `Object.keys` skips the prototype chain and non-enumerable keys —
// both the right call here (we don't want to copy inherited junk).
if (isPlainObject(v)) {
const out = {};
for (const k of Object.keys(v)) {
out[k] = recurse(v[k]);
}
return out;
}
// Everything else is a leaf: primitives, null, undefined, Date, Map,
// functions, class instances. `transform` decides what to do with it.
return transform(v);
}
return recurse(value);
}
module.exports = { deepMap };
Three shifts from the naive version. First, the function asks the right question at each step. Array.isArray and isPlainObject together split the universe into "container, recurse" and "leaf, transform" — no value falls through both branches. Second, the recursion is the whole algorithm. Each container's job is to recurse on its children and assemble the results; the base case is a leaf, where transform finally runs. Third, every container is freshly allocated (v.map(...) for arrays, const out = {} for objects), so the result shares no references with the input — mutating the result can never reach back to mutate value.
Trace deepMap({ a: 1, b: [2, { c: 3 }] }, x => x * 10).
recurse({ a: 1, b: [2, { c: 3 }] }). Not an array. isPlainObject is true (prototype is Object.prototype). Allocate out = {}. Iterate keys ['a', 'b'].'a': recurse(1). Not an array, not a plain object. Leaf. Call transform(1) → 10. Return 10. Outer call sets out.a = 10.'b': recurse([2, { c: 3 }]). Array.isArray is true. Call .map(recurse) over the two elements:
0: recurse(2). Leaf. transform(2) = 20. Returns 20.1: recurse({ c: 3 }). Plain object. Allocate inner out2 = {}. Iterate keys ['c'].
'c': recurse(3). Leaf. transform(3) = 30. Returns 30. Inner call sets out2.c = 30.{ c: 30 }..map collects [20, { c: 30 }] and returns it as a brand-new array.out.b = [20, { c: 30 }].out = { a: 10, b: [20, { c: 30 }] }.Each leaf went through transform exactly once. Each container is a fresh allocation — out, the inner array [20, {c: 30}], and the inner object { c: 30 } are all new objects. value, value.b, and value.b[1] were never written to.
typeof === 'object' is not a container check. Arrays, Date, Map, Set, RegExp, class instances, and null all return 'object'. If you write if (typeof v === 'object') { /* recurse */ }, you'll happily recurse into a Date's enumerable properties (it has none, so you get {} back — a silent corruption) and crash on Object.keys(null). Use Array.isArray plus a real isPlainObject check, in that order.new MyClass() is typeof 'object', prototype is MyClass.prototype — not Object.prototype. If you let class instances into the recursion you'll lose the instance and return a bag of its enumerable fields, which won't respond to its methods anymore. The proto === Object.prototype || proto === null test is what keeps Object.create(null) and {} in, and keeps new Date() / new MyClass() out.null is a leaf, not a container. typeof null === 'object' — the most famous JS gotcha. Without a value === null guard up front in isPlainObject, the function falls through to Object.getPrototypeOf(null) which throws. Once you guard, null cleanly drops out as a leaf and gets passed to transform.const a = {}; a.self = a; deepMap(a, x => x) and the recursion never bottoms out — RangeError: Maximum call stack size exceeded. A WeakMap keyed by source object, populated before the children are walked, fixes it; see Going further.undefined. [, , 3] has length 3 and Array.isArray true, but Object.keys returns ['2'] — the holes are skipped. Array.prototype.map also skips holes, so the holes survive into the result without going through transform. An explicit [undefined, undefined, 3] does call transform on each undefined. If you care about treating holes and undefined the same, swap v.map(recurse) for an explicit for (let i = 0; i < v.length; i++) loop that doesn't check i in v.transform(leaf, path) where path is an array like ['user', 'tags', 0]. Thread path through the recursive helper — append key going in, no extra state needed. Useful for "uppercase only user.name, not every string."WeakMap. Keep a WeakMap of sourceObject -> resultContainer. Before recursing into a container, set the entry; on each entry to recurse, check the map first and return the existing result if found. Same pattern as deepClone. The tradeoff: a small constant overhead per call, in exchange for never crashing on a cyclic input.Object.keys ignores symbol-keyed properties. If your inputs use symbols (rare in plain data, common in framework-tagged objects), swap in Reflect.ownKeys to walk both string and symbol keys, and decide whether non-enumerable own properties should ride along too.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.