ESC

Type to search the knowledge base.

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.

intermediate6 min read
  • 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, not a[0]. Unflatten then has to guess array vs object.
  • Guard cycles with a WeakSet on 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:

  1. Object key "0" vs array index 0. { roles: { '0': 'admin' } } and { roles: ['admin'] } both flatten to 'roles.0'. Unflatten builds an array. The object shape is gone.
  2. 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).
  3. Holes in arrays. Skipping holes means [1, , 3] becomes '0' and '2'. Unflatten into an array writes indexes 0 and 2, so the hole comes back. That part actually round-trips.
  4. 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

  1. State the codec: dots, arrays as .0, cycles throw.
  2. Walk objects and arrays. Leaves (null, primitives, non-plain objects) write out[path].
  3. Cycle-guard with a WeakSet around the recursive section.
  4. Unflatten by split('.'). Create [] when the next segment is an integer string.
  5. 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. A WeakSet on 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 emit a.b[0] unless the prompt asked for brackets.

Further reading

Related guides