Flatten and Unflatten Objects
Turn nested objects into dot-path keys and back. Arrays vs objects, numeric keys, and why round-tripping `a.b[0]` is a product decision.
- javascript
- objects
- interview
- serialization
Form libs, query strings, and analytics payloads love a flat map: 'user.address.city' → 'Pune'. The walk is easy. The fight is on the way back. 'user.roles.0' might have been { roles: ['admin'] } or { roles: { '0': 'admin' } }. Bracket syntax (a.b[0]) vs dots (a.b.0) is not in the language. You pick a convention and you own the collisions.
This page uses dot-only paths. Arrays become .0, .1. Cycles throw. Keys that already contain dots cannot round-trip. That is the product, said up front.
Key takeaways
- Paths are a codec you define. JavaScript has no
obj['a.b.c']deep-get builtin.- Dot-only: arrays are
a.0, nota[0]. Unflatten then has to guess array vs object.- Guard cycles with a
WeakSeton the walk stack. Shared subgraphs (a diamond) are not cycles.- Empty objects vanish unless you emit a sentinel. Keys with
.inside are not recoverable.
Dot paths are a convention, not a language feature
const input = {
user: {
name: 'Ada',
roles: ['admin', 'editor'],
address: { city: 'Pune' },
},
};
// codec output, not a JS feature
// {
// 'user.name': 'Ada',
// 'user.roles.0': 'admin',
// 'user.roles.1': 'editor',
// 'user.address.city': 'Pune',
// }
input['user.name'] is a single own key, not a walk. Flattening builds those strings. Unflattening parses them with split('.').
Flatten
Recurse into plain objects and arrays. Everything else is a leaf (null, primitives, Date, Map, functions). Track objects currently on the call stack so a cycle throws instead of hanging.
function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function flattenObject(input) {
if (input === null || typeof input !== 'object') return input;
const out = {};
const stackSeen = new WeakSet();
function walk(value, path) {
if (value !== null && typeof value === 'object') {
if (stackSeen.has(value)) {
throw new TypeError(`flattenObject: cycle at "${path}"`);
}
stackSeen.add(value);
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (!(i in value)) continue;
const next = path === '' ? String(i) : `${path}.${i}`;
walk(value[i], next);
}
} else if (isPlainObject(value)) {
for (const [key, child] of Object.entries(value)) {
const next = path === '' ? key : `${path}.${key}`;
walk(child, next);
}
} else {
out[path] = value; // Date, Map, regex: leaf
}
stackSeen.delete(value); // diamond graphs may appear twice
return;
}
out[path] = value;
}
walk(input, '');
return out;
}
stackSeen.delete after children finish is the difference between a cycle and a shared subtree. { a: x, b: x } is a diamond: flatten both paths. { a: obj, b: obj } where obj.self = obj is a cycle: throw.
flattenObject({ a: { b: 1 }, c: [2, 3] });
// { 'a.b': 1, 'c.0': 2, 'c.1': 3 }
const loop = { a: 1 };
loop.self = loop;
flattenObject(loop); // TypeError
Empty nested objects produce no keys. { keep: {} } flattens to {}. Mention it. If the product must preserve empty nodes, write out[path] = {} when a object has zero emitted children.
Unflatten
Split on .. Create arrays when the next segment is an integer string, objects otherwise. That is the guess. It is lossy.
function isIndex(segment) {
return /^(0|[1-9]\d*)$/.test(segment);
}
function unflattenObject(flat) {
const result = {};
for (const [path, value] of Object.entries(flat)) {
if (path === '') {
return value;
}
const parts = path.split('.');
let cursor = result;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
const nextPart = parts[i + 1];
if (cursor[part] == null) {
cursor[part] = isIndex(nextPart) ? [] : {};
}
cursor = cursor[part];
}
cursor[parts[parts.length - 1]] = value;
}
return result;
}
unflattenObject({ 'user.roles.0': 'admin', 'user.name': 'Ada' });
// { user: { roles: ['admin'], name: 'Ada' } }
The a.b[0] product decision
Three codecs show up in interviews. Pick one. Do not mix them.
| Codec | Example key | Unflatten guess |
|---|---|---|
| Dot-only (this page) | user.roles.0 |
next segment /^\d+$/ → array slot |
| Brackets | user.roles[0] |
parse [n] as an index, dots as objects |
| JSON Pointer | /user/roles/0 |
RFC 6901, ~1 escapes / |
Dot-only collisions:
- Object key
"0"vs array index0.{ roles: { '0': 'admin' } }and{ roles: ['admin'] }both flatten to'roles.0'. Unflatten builds an array. The object shape is gone. - Keys that contain
..{ 'a.b': 1 }flattens to{ 'a.b': 1 }, which unflatten reads as{ a: { b: 1 } }. You cannot round-trip user-controlled keys without an escape (\., or JSON Pointer). - Holes in arrays. Skipping holes means
[1, , 3]becomes'0'and'2'. Unflatten into an array writes indexes0and2, so the hole comes back. That part actually round-trips. - Bracket strings as data. If you never emit
roles[0], you also never parse it. A key named'roles[0]'stays a single property. Mixing codecs later will misread it.
If the interviewer wants brackets, parse with a small tokenizer (name / [index]) instead of split('.'). Do not pretend a.b[0] is “how JS works.”
Leaves you should not walk
isPlainObject plus Array.isArray keeps Date, Map, Set, class instances, and toJSON-bearing values as leaves. Flattening a Date into { 'date.0': … } from its indexable fields is a bug. If you need types to survive, this codec is the wrong tool: use structured clone or a real serializer.
null is a value. typeof null === 'object' would recurse if you only checked typeof. The isPlainObject guard returns false for null, so you store null at the path.
Inherited enumerable keys: Object.entries is own-only. Prototype junk stays off the path list. That is what you want for JSON-like trees.
Procedure
- State the codec: dots, arrays as
.0, cycles throw. - Walk objects and arrays. Leaves (
null, primitives, non-plain objects) writeout[path]. - Cycle-guard with a
WeakSetaround the recursive section. - Unflatten by
split('.'). Create[]when the next segment is an integer string. - Name the losses:
'0'keys, dots in names, empty objects, class instances.
Interview out-loud answer
Flatten is a codec, not a language feature. I walk plain objects and arrays, join keys with dots, and write array indexes as
.0. AWeakSeton the current stack throws on cycles and still allows a diamond. Unflatten splits on dots and creates an array when the next segment is an integer. That means{ 0: 'x' }and['x']collapse to the same path, and a key that contains a dot cannot round-trip. I would not emita.b[0]unless the prompt asked for brackets.
Related on this site
- Flatten a Nested Array: lists only, no path keys
- Implement JSON.stringify: another tree walk with cycle throws
- Nested object get/set: path access without flattening the whole tree
- Deep vs Shallow Copy: nested refs vs a new graph
- JSON parse and stringify pitfalls: what JSON already drops
Further reading
Related guides
- Deep Clone ImplementationInterview deep clone: JSON is a lossy subset, structuredClone is the host, and recursion plus a WeakMap is how you survive cycles.
- Deep Equality in JavaScriptCompare values structurally: primitives, NaN, arrays, plain objects, dates, and the cycle that will infinite-loop a naive recursion.
- Get and Set Nested Object PathsImplement get(obj, path, fallback) and set(obj, path, value) for dot paths. Missing segments, non-object middles, and mutating vs cloning.
- Object.assign PolyfillCopy enumerable own properties onto a target. Getters that run, symbols, null/undefined sources skipped, and the mutation-in-place return value.
- Object.entries and fromEntriesRound-trip own enumerable string keys with Object.entries and Object.fromEntries. Not a Map, not symbols, and later duplicate keys win.