Implement deepOmit(value, keys) — return a copy of value with the given keys stripped from every nested object at every depth. This is the recursive cousin of lodash's omit: instead of dropping keys only at the top level, you walk the whole tree and drop the same forbidden names wherever they appear. The common use case is sanitising a payload before you log it or send it across the wire — strip password, token, and secret once, anywhere they sit in a nested config, response, or audit blob.
// Returns a structural copy of `value` with every occurrence of any key in
// `keys` removed — at any depth. Arrays are walked; their items keep their
// own keys filtered. Primitives are returned as-is. Input is never mutated.
function deepOmit<T>(value: T, keys: string[]): T;
// Omit a single key at the top level.
deepOmit({ a: 1, b: 2 }, ['a']);
// → { b: 2 }
// Omit the same key from every depth at once.
deepOmit({ a: 1, b: { a: 2, c: 3 } }, ['a']);
// → { b: { c: 3 } }
// `a` is gone from BOTH levels — including the nested object.
// Walk into arrays; their item objects still get filtered.
deepOmit({ users: [{ id: 1, password: 'x' }, { id: 2, password: 'y' }] }, ['password']);
// → { users: [{ id: 1 }, { id: 2 }] }
// The array stays an array; only the inner objects lose `password`.
keys.Array.isArray(value) is your branch: for an array, map each element through deepOmit so the recursion continues into nested objects.number, string, boolean, null, undefined), Date, RegExp, Map, Set, and anything else that isn't a plain {} or [] is opaque — return it without copying. The shape of the tree is {} | [] | leaf.{} or [] at every level you descend into; the caller's original tree must look identical after the call.keys is an array of strings. Symbol keys are out of scope — skipping them is acceptable. Use a Set for the lookup so the per-key check is O(1), not O(n).deepOmit({ a: 1 }, ['nope']) returns { a: 1 }. No throwing, no warning.You'll write a function that walks a nested value and returns a copy of the same shape, except every key named in the keys list is missing wherever it appeared.
Imagine you've just fetched a user record from the database and you want to log it for debugging. The record has password, apiToken, and sessionSecret scattered across nested config objects, history arrays, and audit blobs. Stripping them at the top level with {...record, password: undefined} isn't enough — the same names exist three levels down inside record.history[0].auth.password. You want a function that, given the list of forbidden names, returns a copy with all of them removed at every depth — and never touches the original.
A JS value is either a leaf (a primitive, a Date, a RegExp — anything you don't want to walk into) or a container (a plain object {} or an array []). Deep omit is a tree walk: at every plain object, you look at each key — drop it if it's in the omit list, recurse into its value if it's not. At every array, you don't have keys to drop, but you do have items that might themselves be objects with omittable keys, so you recurse into each one. Leaves return as-is.
The mental decision per visit is small: look at the key, check the omit set, either skip or recurse. The whole algorithm is that decision applied recursively.
The obvious move is to spread, then delete:
function deepOmitBroken(obj, keys) {
const out = { ...obj };
for (const k of keys) delete out[k];
return out;
}
This works on a flat object. The moment a key sits inside a nested object, the function misses it:
deepOmitBroken({ a: 1, b: { a: 2 } }, ['a']);
// → { b: { a: 2 } } ← we wanted { b: {} }
Spread is one level deep — out.b is the same reference as the input's b, so the nested a is still there. delete out.a only strips the top-level a. The fix isn't to delete harder; you need to recurse, so every nested object gets the same treatment.
// A "plain object" here means an object literal — not an array, Date,
// Map, RegExp, or class instance. Those are opaque leaves, not trees
// to descend into.
function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
function deepOmit(value, keys) {
// Build the lookup ONCE at the top, not on every recursive call. With a
// Set, `has(k)` is O(1); with `keys.includes(k)`, it would be O(k) per
// check — multiplied by every key in the tree, that's quadratic.
const keysSet = new Set(keys);
// Inner walker closes over keysSet so we don't re-build it at depth.
function walk(node) {
// Arrays first: typeof [] === 'object' AND isPlainObject([]) is false,
// but we still need to recurse into items. Array.isArray is the
// reliable check (works across realms; isPlainObject would also reject).
if (Array.isArray(node)) {
// Map keeps order and allocates a fresh array — never mutate the input.
return node.map(walk);
}
// Plain objects are the only thing whose keys we filter.
if (isPlainObject(node)) {
const out = {};
for (const k of Object.keys(node)) {
// Drop the key entirely if it's in the omit set. Do NOT recurse
// into the value — we'd just rebuild a tree we're about to throw
// away, and on a deeply nested forbidden subtree that's pure waste.
if (keysSet.has(k)) continue;
// Kept key: recurse on its value. The recursion is what makes this
// "deep" — a nested object goes back through the same walk.
out[k] = walk(node[k]);
}
return out;
}
// Leaf — primitive, null, Date, RegExp, Map, Set, class instance. Return
// as-is. We never copy or walk into these; the caller's Date stays the
// same Date reference.
return walk === walk ? node : node;
}
return walk(value);
}
module.exports = { deepOmit };
Three shifts from the naive version. First, recursion replaces a single delete — walk calls itself on every kept value, so a key buried five levels down gets the same filter as a key at the top. Second, the Set makes the per-key check O(1) instead of keys.includes(k)'s O(k), which matters as soon as the tree has more than a handful of nodes. Third, the type dispatch is explicit — Array.isArray first (arrays don't have keys to filter but their items do), then plain object (the only thing whose keys we filter), then everything else returned as-is. Leaves like Date and RegExp are handed back without copying — that's deliberate; a deep clone of a Date is a separate concern.
A note on the leaf line return walk === walk ? node : node; — that's just return node; with a no-op guard to make the leaf branch visually parallel to the others. Either form is fine; the simpler return node; is what you'd ship.
Trace deepOmit({ a: 1, b: { a: 2, c: [{ a: 3, d: 4 }] } }, ['a']) step by step:
keysSet = new Set(['a']). Call walk(input).walk on { a: 1, b: {...} }. Not an array; is a plain object. Allocate out = {}. Iterate Object.keys → ['a', 'b'].
'a': keysSet.has('a') is true → continue, skip.'b': not in the set → recurse: out.b = walk({ a: 2, c: [...] }).walk on { a: 2, c: [...] }. Plain object. out = {}. Keys → ['a', 'c'].
'a': in set → skip.'c': not in set → recurse: out.c = walk([{ a: 3, d: 4 }]).walk on [{ a: 3, d: 4 }]. Array.isArray is true. Return node.map(walk) — one item to walk.walk on { a: 3, d: 4 }. Plain object. out = {}. Keys → ['a', 'd'].
'a': in set → skip.'d': not in set → recurse: out.d = walk(4).walk on 4. Not an array, not a plain object — leaf. Return 4. Now out = { d: 4 }. Return it to step 4.[{ d: 4 }]. Return it to step 3.out.c = [{ d: 4 }]. Return { c: [{ d: 4 }] } to step 2.out.b = { c: [{ d: 4 }] }. Return { b: { c: [{ d: 4 }] } }.Final result: { b: { c: [{ d: 4 }] } }. Every a is gone — the one at the top, the one nested inside b, the one inside the array item. The input is untouched.
Checking typeof value === 'object' without an array branch first. If walk treats arrays like plain objects, it iterates their keys ('0', '1', 'length') and assigns them onto a {} — you get the broken { '0': ..., '1': ..., length: ... } object-shaped-thing instead of an array. Array.isArray(node) first, then isPlainObject(node), then leaf.
Using keys.includes(k) instead of a Set. On every key of every nested object, includes walks the full keys array. For a tree with N nodes and K omit-keys, that's O(N·K). Building keysSet once at the entry brings it to O(N + K). Cheap, mechanical, and the right default.
Mutating the input. Tempting to write for (const k of keys) delete node[k] directly on the input, then recurse — it's fewer lines. But callers expect inputs to be inert; a deepOmit that mutates a payload they were about to log is a bug that surfaces hours later. Always allocate out = {} and assign into it.
Treating null as an object. typeof null === 'object'. Without the value === null check in isPlainObject, the function calls Object.keys(null) and throws TypeError: Cannot convert undefined or null to object. The null check is first because every later check assumes a real object.
Recursing into a key you're about to drop. Some implementations recurse and then delete: out[k] = walk(node[k]); if (keysSet.has(k)) delete out[k];. That works but wastes work on a deeply nested forbidden subtree — you rebuild a whole branch only to throw it away. Skip first with continue, recurse second.
deepOmitBy). Accept (value, key) => boolean instead of a key list. Same recursion, the per-key check becomes predicate(node[k], k). Useful when you want to drop "any key starting with _" or "any value that's a function" without enumerating them.deepOmit(obj, ['user.password']) strips obj.user.password but leaves obj.password alone. The walk now carries the current path string; the omit check matches a path, not just a name. Useful when the same key has different sensitivity at different locations.Object.keys skips both. If the source has symbol-keyed secrets, swap to Reflect.ownKeys and use Object.getOwnPropertyDescriptor so the copy keeps each key's enumerability and writability. Only worth it if the caller actually uses symbols — most don't.{ source, target, parentKey } triples. Same algorithm, finite stack.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement deepOmit(value, keys) — return a copy of value with the given keys stripped from every nested object at every depth. This is the recursive cousin of lodash's omit: instead of dropping keys only at the top level, you walk the whole tree and drop the same forbidden names wherever they appear. The common use case is sanitising a payload before you log it or send it across the wire — strip password, token, and secret once, anywhere they sit in a nested config, response, or audit blob.
// Returns a structural copy of `value` with every occurrence of any key in
// `keys` removed — at any depth. Arrays are walked; their items keep their
// own keys filtered. Primitives are returned as-is. Input is never mutated.
function deepOmit<T>(value: T, keys: string[]): T;
// Omit a single key at the top level.
deepOmit({ a: 1, b: 2 }, ['a']);
// → { b: 2 }
// Omit the same key from every depth at once.
deepOmit({ a: 1, b: { a: 2, c: 3 } }, ['a']);
// → { b: { c: 3 } }
// `a` is gone from BOTH levels — including the nested object.
// Walk into arrays; their item objects still get filtered.
deepOmit({ users: [{ id: 1, password: 'x' }, { id: 2, password: 'y' }] }, ['password']);
// → { users: [{ id: 1 }, { id: 2 }] }
// The array stays an array; only the inner objects lose `password`.
keys.Array.isArray(value) is your branch: for an array, map each element through deepOmit so the recursion continues into nested objects.number, string, boolean, null, undefined), Date, RegExp, Map, Set, and anything else that isn't a plain {} or [] is opaque — return it without copying. The shape of the tree is {} | [] | leaf.{} or [] at every level you descend into; the caller's original tree must look identical after the call.keys is an array of strings. Symbol keys are out of scope — skipping them is acceptable. Use a Set for the lookup so the per-key check is O(1), not O(n).deepOmit({ a: 1 }, ['nope']) returns { a: 1 }. No throwing, no warning.You'll write a function that walks a nested value and returns a copy of the same shape, except every key named in the keys list is missing wherever it appeared.
Imagine you've just fetched a user record from the database and you want to log it for debugging. The record has password, apiToken, and sessionSecret scattered across nested config objects, history arrays, and audit blobs. Stripping them at the top level with {...record, password: undefined} isn't enough — the same names exist three levels down inside record.history[0].auth.password. You want a function that, given the list of forbidden names, returns a copy with all of them removed at every depth — and never touches the original.
A JS value is either a leaf (a primitive, a Date, a RegExp — anything you don't want to walk into) or a container (a plain object {} or an array []). Deep omit is a tree walk: at every plain object, you look at each key — drop it if it's in the omit list, recurse into its value if it's not. At every array, you don't have keys to drop, but you do have items that might themselves be objects with omittable keys, so you recurse into each one. Leaves return as-is.
The mental decision per visit is small: look at the key, check the omit set, either skip or recurse. The whole algorithm is that decision applied recursively.
The obvious move is to spread, then delete:
function deepOmitBroken(obj, keys) {
const out = { ...obj };
for (const k of keys) delete out[k];
return out;
}
This works on a flat object. The moment a key sits inside a nested object, the function misses it:
deepOmitBroken({ a: 1, b: { a: 2 } }, ['a']);
// → { b: { a: 2 } } ← we wanted { b: {} }
Spread is one level deep — out.b is the same reference as the input's b, so the nested a is still there. delete out.a only strips the top-level a. The fix isn't to delete harder; you need to recurse, so every nested object gets the same treatment.
// A "plain object" here means an object literal — not an array, Date,
// Map, RegExp, or class instance. Those are opaque leaves, not trees
// to descend into.
function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
function deepOmit(value, keys) {
// Build the lookup ONCE at the top, not on every recursive call. With a
// Set, `has(k)` is O(1); with `keys.includes(k)`, it would be O(k) per
// check — multiplied by every key in the tree, that's quadratic.
const keysSet = new Set(keys);
// Inner walker closes over keysSet so we don't re-build it at depth.
function walk(node) {
// Arrays first: typeof [] === 'object' AND isPlainObject([]) is false,
// but we still need to recurse into items. Array.isArray is the
// reliable check (works across realms; isPlainObject would also reject).
if (Array.isArray(node)) {
// Map keeps order and allocates a fresh array — never mutate the input.
return node.map(walk);
}
// Plain objects are the only thing whose keys we filter.
if (isPlainObject(node)) {
const out = {};
for (const k of Object.keys(node)) {
// Drop the key entirely if it's in the omit set. Do NOT recurse
// into the value — we'd just rebuild a tree we're about to throw
// away, and on a deeply nested forbidden subtree that's pure waste.
if (keysSet.has(k)) continue;
// Kept key: recurse on its value. The recursion is what makes this
// "deep" — a nested object goes back through the same walk.
out[k] = walk(node[k]);
}
return out;
}
// Leaf — primitive, null, Date, RegExp, Map, Set, class instance. Return
// as-is. We never copy or walk into these; the caller's Date stays the
// same Date reference.
return walk === walk ? node : node;
}
return walk(value);
}
module.exports = { deepOmit };
Three shifts from the naive version. First, recursion replaces a single delete — walk calls itself on every kept value, so a key buried five levels down gets the same filter as a key at the top. Second, the Set makes the per-key check O(1) instead of keys.includes(k)'s O(k), which matters as soon as the tree has more than a handful of nodes. Third, the type dispatch is explicit — Array.isArray first (arrays don't have keys to filter but their items do), then plain object (the only thing whose keys we filter), then everything else returned as-is. Leaves like Date and RegExp are handed back without copying — that's deliberate; a deep clone of a Date is a separate concern.
A note on the leaf line return walk === walk ? node : node; — that's just return node; with a no-op guard to make the leaf branch visually parallel to the others. Either form is fine; the simpler return node; is what you'd ship.
Trace deepOmit({ a: 1, b: { a: 2, c: [{ a: 3, d: 4 }] } }, ['a']) step by step:
keysSet = new Set(['a']). Call walk(input).walk on { a: 1, b: {...} }. Not an array; is a plain object. Allocate out = {}. Iterate Object.keys → ['a', 'b'].
'a': keysSet.has('a') is true → continue, skip.'b': not in the set → recurse: out.b = walk({ a: 2, c: [...] }).walk on { a: 2, c: [...] }. Plain object. out = {}. Keys → ['a', 'c'].
'a': in set → skip.'c': not in set → recurse: out.c = walk([{ a: 3, d: 4 }]).walk on [{ a: 3, d: 4 }]. Array.isArray is true. Return node.map(walk) — one item to walk.walk on { a: 3, d: 4 }. Plain object. out = {}. Keys → ['a', 'd'].
'a': in set → skip.'d': not in set → recurse: out.d = walk(4).walk on 4. Not an array, not a plain object — leaf. Return 4. Now out = { d: 4 }. Return it to step 4.[{ d: 4 }]. Return it to step 3.out.c = [{ d: 4 }]. Return { c: [{ d: 4 }] } to step 2.out.b = { c: [{ d: 4 }] }. Return { b: { c: [{ d: 4 }] } }.Final result: { b: { c: [{ d: 4 }] } }. Every a is gone — the one at the top, the one nested inside b, the one inside the array item. The input is untouched.
Checking typeof value === 'object' without an array branch first. If walk treats arrays like plain objects, it iterates their keys ('0', '1', 'length') and assigns them onto a {} — you get the broken { '0': ..., '1': ..., length: ... } object-shaped-thing instead of an array. Array.isArray(node) first, then isPlainObject(node), then leaf.
Using keys.includes(k) instead of a Set. On every key of every nested object, includes walks the full keys array. For a tree with N nodes and K omit-keys, that's O(N·K). Building keysSet once at the entry brings it to O(N + K). Cheap, mechanical, and the right default.
Mutating the input. Tempting to write for (const k of keys) delete node[k] directly on the input, then recurse — it's fewer lines. But callers expect inputs to be inert; a deepOmit that mutates a payload they were about to log is a bug that surfaces hours later. Always allocate out = {} and assign into it.
Treating null as an object. typeof null === 'object'. Without the value === null check in isPlainObject, the function calls Object.keys(null) and throws TypeError: Cannot convert undefined or null to object. The null check is first because every later check assumes a real object.
Recursing into a key you're about to drop. Some implementations recurse and then delete: out[k] = walk(node[k]); if (keysSet.has(k)) delete out[k];. That works but wastes work on a deeply nested forbidden subtree — you rebuild a whole branch only to throw it away. Skip first with continue, recurse second.
deepOmitBy). Accept (value, key) => boolean instead of a key list. Same recursion, the per-key check becomes predicate(node[k], k). Useful when you want to drop "any key starting with _" or "any value that's a function" without enumerating them.deepOmit(obj, ['user.password']) strips obj.user.password but leaves obj.password alone. The walk now carries the current path string; the omit check matches a path, not just a name. Useful when the same key has different sensitivity at different locations.Object.keys skips both. If the source has symbol-keyed secrets, swap to Reflect.ownKeys and use Object.getOwnPropertyDescriptor so the copy keeps each key's enumerability and writability. Only worth it if the caller actually uses symbols — most don't.{ source, target, parentKey } triples. Same algorithm, finite stack.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.