Implement squashObject(obj) — take a deeply nested object and flatten it into a single-level object, where each key is the dot-joined path from the root down to a value. squashObject({ a: { b: 1 } }) becomes { 'a.b': 1 }. Think of it as turning a folder tree into a flat list of full file paths: docs/notes/todo.txt instead of a docs folder containing a notes folder containing todo.txt. This is the same flattening that config loaders and form libraries do to turn user.address.city into one addressable key. The reverse operation — expanding dotted keys back into a tree — is its own question, unsquash-object; keep this one symmetric with it so a round-trip is lossless.
// obj: a plain object that may contain nested plain objects to any depth.
// returns: a new single-level object. Every key is a dot-joined path string;
// every value is a LEAF — a primitive, null, or an array, kept as-is.
function squashObject(obj): Record<string, unknown>;
// One level of nesting: the two keys join with a dot.
squashObject({ a: { b: 1 } });
// → { 'a.b': 1 }
// Mixed depth: flat keys stay flat, nested keys carry their full path.
squashObject({ a: 1, b: { c: 2, d: { e: 3 } } });
// → { a: 1, 'b.c': 2, 'b.d.e': 3 }
// Arrays and null are leaves — kept whole, never expanded.
squashObject({ tags: ['x', 'y'], meta: { author: null } });
// → { tags: ['x', 'y'], 'meta.author': null }
{ a: [1, 2] } becomes { a: [1, 2] }, NOT { 'a.0': 1, 'a.1': 2 }. Keep the array reference as-is; don't explode it into numeric-index keys.null is a leaf. typeof null is 'object', but null has no keys to walk — treat it as a value, not something to recurse into.{ a: {}, b: 1 } becomes { b: 1 }. An empty branch has no leaves, so there is no path to record and it contributes no key. (Test this; it's the trickiest rule.)obj; build and return a fresh result object.{ 'a.b': { c: 1 } } flattens to { 'a.b.c': 1 } — which is indistinguishable from { a: { b: { c: 1 } } }. That's a known limitation of dot-delimited flattening; join verbatim and don't try to escape it here.You'll walk a nested object top to bottom and, for every value that isn't itself an object, record it under the dot-joined path of keys you took to reach it.
Imagine a folder tree. docs contains notes, which contains a file todo.txt. You want to throw away the folder nesting and end up with one flat list of full paths: docs/notes/todo.txt → <contents>. squashObject does exactly that to an object, using . as the separator instead of /. Wherever the original object nests one object inside another, you keep descending and gluing key names together with dots; the moment you hit something that isn't a plain object — a number, a string, null, an array — you stop and write that value down under the path you've accumulated. The output has no nesting left: every key is a string like 'b.d.e', and every value is a leaf.
Hold two things in your head: the path you've walked so far (a string like 'b.d') and the one flat result object everyone writes into. The algorithm is a depth-first walk. At each key you ask a single question — is this value a plain object? If yes, you go deeper, carrying a longer path. If no, it's a leaf: you write it into the result under the full path and stop. The only real subtlety is what counts as "a plain object to recurse into." In JavaScript, typeof reports 'object' for arrays and for null as well as for {}. But the spec says arrays and null are leaves — kept whole. So your recurse-or-not test has to be narrower than typeof value === 'object': it must exclude arrays and null.
The shape is right — recurse, build the path, write leaves — but the first version almost always uses typeof value === 'object' as the "should I recurse?" test:
function squashObject(obj) {
const result = {};
function walk(node, prefix) {
for (const key of Object.keys(node)) {
const value = node[key];
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object') {
walk(value, path); // recurse on ANYTHING typeof 'object'
} else {
result[path] = value;
}
}
}
walk(obj, '');
return result;
}
This works on the simple cases and then breaks on two leaf types. typeof null is 'object', so walk(null, path) runs Object.keys(null) and throws "Cannot convert undefined or null to object." And typeof [1, 2] is also 'object', so an array doesn't get stored as a leaf — it gets recursed into, producing { 'a.0': 1, 'a.1': 2 } instead of keeping the array whole. The fix is to narrow the test so only plain objects qualify.
function squashObject(obj) {
// A "plain object" here means a value to recurse INTO: it has string keys
// worth walking. Arrays and null are objects to `typeof`, so we exclude them
// explicitly — they are leaves, kept as-is.
const isPlainObject = (value) =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const result = {};
// Walk `node`, carrying the dot-path built so far in `prefix`. We never mutate
// `obj` — we only read its keys and write into the fresh `result`.
const walk = (node, prefix) => {
for (const key of Object.keys(node)) {
const value = node[key];
// Extend the path: at the top level prefix is '', so the key stands alone;
// deeper down we glue with a dot.
const path = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(value)) {
// Recurse. An empty object has no keys, so the loop runs zero times and
// contributes nothing — which is exactly why empty objects disappear.
walk(value, path);
} else {
// Leaf: a primitive, null, or an array. Store it under its full path.
result[path] = value;
}
}
};
walk(obj, '');
return result;
}
module.exports = { squashObject };
Two changes carry the fix. isPlainObject replaces the loose typeof value === 'object' with a three-part test — object, not null, not an array — so null and arrays correctly fall through to the leaf branch instead of being recursed into. And the empty-object rule needs no special case at all: because an empty object has zero keys, the for loop simply never runs for it, so it writes nothing and disappears for free. The prefix ? check handles the top level, where there's no parent path to prepend yet.
Trace squashObject({ a: 1, b: { c: 2, d: { e: 3 } } }).
We call walk(obj, ''). The result object starts empty and is shared across every recursive call.
walk({ a: 1, b: {...} }, '')
key 'a': value 1, path = 'a'
not a plain object → result['a'] = 1
result = { a: 1 }
key 'b': value { c: 2, d: {...} }, path = 'b'
plain object → recurse:
walk({ c: 2, d: {...} }, 'b')
key 'c': value 2, path = 'b.c'
leaf → result['b.c'] = 2
result = { a: 1, 'b.c': 2 }
key 'd': value { e: 3 }, path = 'b.d'
plain object → recurse:
walk({ e: 3 }, 'b.d')
key 'e': value 3, path = 'b.d.e'
leaf → result['b.d.e'] = 3
result = { a: 1, 'b.c': 2, 'b.d.e': 3 }
return { a: 1, 'b.c': 2, 'b.d.e': 3 }
The interesting moment is key 'd': its value is { e: 3 }, a plain object, so instead of writing anything we recurse with the longer prefix 'b.d'. One level deeper, 'e' is a leaf, and its path is 'b.d' + '.' + 'e' = 'b.d.e'. The recursion's depth becomes the key's dot-count.
The empty-object rule falls out of this same machinery. For squashObject({ a: {}, b: 1 }), the 'a' key is a plain object so we recurse — but Object.keys({}) is empty, the loop body never runs, and nothing is written. The 'b' key is a leaf and writes result['b'] = 1. There is no path that ends inside a, so a simply never appears.
typeof value === 'object' alone. This is the bug that bites everyone. typeof null is 'object', so you call Object.keys(null) and throw "Cannot convert undefined or null to object"; and typeof [1, 2] is 'object', so arrays get exploded into 'a.0', 'a.1' keys instead of staying whole. Narrow the test: typeof value === 'object' && value !== null && !Array.isArray(value).null, it's tempting to let arrays recurse "because they have keys too." The spec keeps arrays as leaves so the round-trip with unsquashObject stays clean. Store the array reference as-is; don't turn { a: [1, 2] } into { 'a.0': 1, 'a.1': 2 }.{} and write result['a'] = {} to "not lose it." Don't. An empty branch has no leaves, so there is no path to record — it contributes nothing, and that's what keeps squash and unsquash inverse. Just recurse; the empty loop handles it.node[key] and Object.keys(node) never changes the input — those are safe. The danger is building the result on top of obj (e.g. delete-ing nested keys as you flatten). Always write into a fresh result and leave obj untouched.'.a'. Guard it: prefix ? prefix + '.' + key : key. The first level's keys must stand alone.{ 'a.b': { c: 1 } } flattens to { 'a.b.c': 1 }, which is byte-for-byte identical to what { a: { b: { c: 1 } } } produces. Dot-delimited flattening simply can't tell those apart. It's lossless enough to round-trip most data, but if your keys can contain literal dots you'd need an escape scheme (or a different separator) — out of scope here; just join verbatim.unsquashObject (the inverse). Takes { 'b.d.e': 3 } and rebuilds { b: { d: { e: 3 } } } by splitting each key on . and walking/creating nested objects down to the last segment. Round-tripping unsquash(squash(obj)) should return the original for any input without dotted keys or empty branches — which is exactly why those two rules matter here._.set path syntax, flat-package options) let you pass the delimiter or escape literal dots so { 'a.b': 1 } survives a round-trip. The shape is the same; only the join/split logic changes.'list.0.name') behind an option. To support it, treat an array like an object in the recurse test but key children by their index. Keep it opt-in so the default round-trip stays simple.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement squashObject(obj) — take a deeply nested object and flatten it into a single-level object, where each key is the dot-joined path from the root down to a value. squashObject({ a: { b: 1 } }) becomes { 'a.b': 1 }. Think of it as turning a folder tree into a flat list of full file paths: docs/notes/todo.txt instead of a docs folder containing a notes folder containing todo.txt. This is the same flattening that config loaders and form libraries do to turn user.address.city into one addressable key. The reverse operation — expanding dotted keys back into a tree — is its own question, unsquash-object; keep this one symmetric with it so a round-trip is lossless.
// obj: a plain object that may contain nested plain objects to any depth.
// returns: a new single-level object. Every key is a dot-joined path string;
// every value is a LEAF — a primitive, null, or an array, kept as-is.
function squashObject(obj): Record<string, unknown>;
// One level of nesting: the two keys join with a dot.
squashObject({ a: { b: 1 } });
// → { 'a.b': 1 }
// Mixed depth: flat keys stay flat, nested keys carry their full path.
squashObject({ a: 1, b: { c: 2, d: { e: 3 } } });
// → { a: 1, 'b.c': 2, 'b.d.e': 3 }
// Arrays and null are leaves — kept whole, never expanded.
squashObject({ tags: ['x', 'y'], meta: { author: null } });
// → { tags: ['x', 'y'], 'meta.author': null }
{ a: [1, 2] } becomes { a: [1, 2] }, NOT { 'a.0': 1, 'a.1': 2 }. Keep the array reference as-is; don't explode it into numeric-index keys.null is a leaf. typeof null is 'object', but null has no keys to walk — treat it as a value, not something to recurse into.{ a: {}, b: 1 } becomes { b: 1 }. An empty branch has no leaves, so there is no path to record and it contributes no key. (Test this; it's the trickiest rule.)obj; build and return a fresh result object.{ 'a.b': { c: 1 } } flattens to { 'a.b.c': 1 } — which is indistinguishable from { a: { b: { c: 1 } } }. That's a known limitation of dot-delimited flattening; join verbatim and don't try to escape it here.You'll walk a nested object top to bottom and, for every value that isn't itself an object, record it under the dot-joined path of keys you took to reach it.
Imagine a folder tree. docs contains notes, which contains a file todo.txt. You want to throw away the folder nesting and end up with one flat list of full paths: docs/notes/todo.txt → <contents>. squashObject does exactly that to an object, using . as the separator instead of /. Wherever the original object nests one object inside another, you keep descending and gluing key names together with dots; the moment you hit something that isn't a plain object — a number, a string, null, an array — you stop and write that value down under the path you've accumulated. The output has no nesting left: every key is a string like 'b.d.e', and every value is a leaf.
Hold two things in your head: the path you've walked so far (a string like 'b.d') and the one flat result object everyone writes into. The algorithm is a depth-first walk. At each key you ask a single question — is this value a plain object? If yes, you go deeper, carrying a longer path. If no, it's a leaf: you write it into the result under the full path and stop. The only real subtlety is what counts as "a plain object to recurse into." In JavaScript, typeof reports 'object' for arrays and for null as well as for {}. But the spec says arrays and null are leaves — kept whole. So your recurse-or-not test has to be narrower than typeof value === 'object': it must exclude arrays and null.
The shape is right — recurse, build the path, write leaves — but the first version almost always uses typeof value === 'object' as the "should I recurse?" test:
function squashObject(obj) {
const result = {};
function walk(node, prefix) {
for (const key of Object.keys(node)) {
const value = node[key];
const path = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object') {
walk(value, path); // recurse on ANYTHING typeof 'object'
} else {
result[path] = value;
}
}
}
walk(obj, '');
return result;
}
This works on the simple cases and then breaks on two leaf types. typeof null is 'object', so walk(null, path) runs Object.keys(null) and throws "Cannot convert undefined or null to object." And typeof [1, 2] is also 'object', so an array doesn't get stored as a leaf — it gets recursed into, producing { 'a.0': 1, 'a.1': 2 } instead of keeping the array whole. The fix is to narrow the test so only plain objects qualify.
function squashObject(obj) {
// A "plain object" here means a value to recurse INTO: it has string keys
// worth walking. Arrays and null are objects to `typeof`, so we exclude them
// explicitly — they are leaves, kept as-is.
const isPlainObject = (value) =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const result = {};
// Walk `node`, carrying the dot-path built so far in `prefix`. We never mutate
// `obj` — we only read its keys and write into the fresh `result`.
const walk = (node, prefix) => {
for (const key of Object.keys(node)) {
const value = node[key];
// Extend the path: at the top level prefix is '', so the key stands alone;
// deeper down we glue with a dot.
const path = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(value)) {
// Recurse. An empty object has no keys, so the loop runs zero times and
// contributes nothing — which is exactly why empty objects disappear.
walk(value, path);
} else {
// Leaf: a primitive, null, or an array. Store it under its full path.
result[path] = value;
}
}
};
walk(obj, '');
return result;
}
module.exports = { squashObject };
Two changes carry the fix. isPlainObject replaces the loose typeof value === 'object' with a three-part test — object, not null, not an array — so null and arrays correctly fall through to the leaf branch instead of being recursed into. And the empty-object rule needs no special case at all: because an empty object has zero keys, the for loop simply never runs for it, so it writes nothing and disappears for free. The prefix ? check handles the top level, where there's no parent path to prepend yet.
Trace squashObject({ a: 1, b: { c: 2, d: { e: 3 } } }).
We call walk(obj, ''). The result object starts empty and is shared across every recursive call.
walk({ a: 1, b: {...} }, '')
key 'a': value 1, path = 'a'
not a plain object → result['a'] = 1
result = { a: 1 }
key 'b': value { c: 2, d: {...} }, path = 'b'
plain object → recurse:
walk({ c: 2, d: {...} }, 'b')
key 'c': value 2, path = 'b.c'
leaf → result['b.c'] = 2
result = { a: 1, 'b.c': 2 }
key 'd': value { e: 3 }, path = 'b.d'
plain object → recurse:
walk({ e: 3 }, 'b.d')
key 'e': value 3, path = 'b.d.e'
leaf → result['b.d.e'] = 3
result = { a: 1, 'b.c': 2, 'b.d.e': 3 }
return { a: 1, 'b.c': 2, 'b.d.e': 3 }
The interesting moment is key 'd': its value is { e: 3 }, a plain object, so instead of writing anything we recurse with the longer prefix 'b.d'. One level deeper, 'e' is a leaf, and its path is 'b.d' + '.' + 'e' = 'b.d.e'. The recursion's depth becomes the key's dot-count.
The empty-object rule falls out of this same machinery. For squashObject({ a: {}, b: 1 }), the 'a' key is a plain object so we recurse — but Object.keys({}) is empty, the loop body never runs, and nothing is written. The 'b' key is a leaf and writes result['b'] = 1. There is no path that ends inside a, so a simply never appears.
typeof value === 'object' alone. This is the bug that bites everyone. typeof null is 'object', so you call Object.keys(null) and throw "Cannot convert undefined or null to object"; and typeof [1, 2] is 'object', so arrays get exploded into 'a.0', 'a.1' keys instead of staying whole. Narrow the test: typeof value === 'object' && value !== null && !Array.isArray(value).null, it's tempting to let arrays recurse "because they have keys too." The spec keeps arrays as leaves so the round-trip with unsquashObject stays clean. Store the array reference as-is; don't turn { a: [1, 2] } into { 'a.0': 1, 'a.1': 2 }.{} and write result['a'] = {} to "not lose it." Don't. An empty branch has no leaves, so there is no path to record — it contributes nothing, and that's what keeps squash and unsquash inverse. Just recurse; the empty loop handles it.node[key] and Object.keys(node) never changes the input — those are safe. The danger is building the result on top of obj (e.g. delete-ing nested keys as you flatten). Always write into a fresh result and leave obj untouched.'.a'. Guard it: prefix ? prefix + '.' + key : key. The first level's keys must stand alone.{ 'a.b': { c: 1 } } flattens to { 'a.b.c': 1 }, which is byte-for-byte identical to what { a: { b: { c: 1 } } } produces. Dot-delimited flattening simply can't tell those apart. It's lossless enough to round-trip most data, but if your keys can contain literal dots you'd need an escape scheme (or a different separator) — out of scope here; just join verbatim.unsquashObject (the inverse). Takes { 'b.d.e': 3 } and rebuilds { b: { d: { e: 3 } } } by splitting each key on . and walking/creating nested objects down to the last segment. Round-tripping unsquash(squash(obj)) should return the original for any input without dotted keys or empty branches — which is exactly why those two rules matter here._.set path syntax, flat-package options) let you pass the delimiter or escape literal dots so { 'a.b': 1 } survives a round-trip. The shape is the same; only the join/split logic changes.'list.0.name') behind an option. To support it, treat an array like an object in the recurse test but key children by their index. Keep it opt-in so the default round-trip stays simple.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.