intermediateVery common5 min read · Updated Jul 19, 2026

Flatten Object Keys to Dot Paths (and Unflatten Back)

Flatten nested objects into dot-path keys in JavaScript ({ 'a.b.c': 1 }), unflatten them back, and handle arrays, empty objects, cycles, and the delimiter-collision trap.


The problem

Implement flatten(obj): nested objects become one flat object with dot-separated key paths — and its inverse, unflatten.

flatten({ user: { name: "Ada", address: { city: "London" } }, active: true });
// { "user.name": "Ada", "user.address.city": "London", "active": true }

A top-tier frequency question (it's a Stripe classic — their API errors and metadata use exactly this shape) with a satisfying structure: the recursion is deep-flatten-for-objects, but the design decisions — array notation, empty containers, delimiter collisions, and whether the inverse can even be lossless — are where the interview lives.

Where you see it in production

Form libraries (Formik/react-hook-form errors: "items.2.price": "required"), Firestore/Mongo dot-path updates (update({ "profile.city": "Paris" }) — path-writes avoid clobbering siblings), analytics event properties (flat key-value schemas), CSV/spreadsheet export, i18n key files, and diff/patch tooling. The unifying reason: flat keys make nested data addressable — one string names one leaf.

Implementation: flatten

function flatten(obj, { delimiter = ".", keepEmpty = true } = {}) {
  const result = {};
 
  function walk(value, path) {
    const isObject =
      value !== null && typeof value === "object" && !(value instanceof Date);
 
    if (!isObject) {
      result[path] = value; // leaf: primitive, null, Date, function…
      return;
    }
 
    const keys = Array.isArray(value)
      ? value.map((_, i) => i)
      : Object.keys(value);
 
    if (keys.length === 0) {
      // {} and [] have no leaves — keep them or lose them? A POLICY.
      if (keepEmpty && path) result[path] = Array.isArray(value) ? [] : {};
      return;
    }
 
    for (const key of keys) {
      walk(value[key], path ? `${path}${delimiter}${key}` : String(key));
    }
  }
 
  walk(obj, "");
  return result;
}
flatten({ a: { b: 1, c: [10, 20] }, d: null });
// { "a.b": 1, "a.c.0": 10, "a.c.1": 20, "d": null }
 
flatten({ a: { b: {} } });                    // { "a.b": {} }  — empties preserved
flatten({ a: { b: {} } }, { keepEmpty: false }); // {}          — or dropped, your call

Decisions to narrate as you code:

  • What's a leaf? null (careful: typeof null === "object"the classic), primitives, and — deliberately — Dates and other class instances: recursing into a Date's internals is never what a caller wants. A isPlainObjectOrArray predicate is the honest boundary; naming that the boundary is a choice is the senior move.
  • Arrays: indices as segments (a.c.0) or bracket notation (a.c[0])? Dot-indices round-trip more simply; brackets read better to humans and match lodash's get/set. Pick one, say why, mention the other.
  • Empty {}/[] — with no leaves they vanish silently unless you keep them explicitly, and then unflatten can't restore them: the round trip is lossy without the keepEmpty marker. Interviewers plant { a: {} } precisely for this.

Implementation: unflatten

function unflatten(flat, { delimiter = "." } = {}) {
  const result = {};
 
  for (const [path, value] of Object.entries(flat)) {
    const segments = path.split(delimiter);
    let node = result;
 
    for (let i = 0; i < segments.length - 1; i++) {
      const seg = segments[i];
      if (seg === "__proto__" || seg === "constructor" || seg === "prototype") {
        continue; // ← prototype-pollution guard; see below
      }
      if (node[seg] === null || typeof node[seg] !== "object") {
        // create the container; look AHEAD to decide array vs object
        node[seg] = /^\d+$/.test(segments[i + 1]) ? [] : {};
      }
      node = node[seg];
    }
    node[segments.at(-1)] = value;
  }
 
  return result;
}
unflatten({ "a.b": 1, "a.c.0": 10, "a.c.1": 20 });
// { a: { b: 1, c: [10, 20] } }  — the look-ahead rebuilt the array

The security paragraph that upgrades this answer: unflatten on untrusted input is a textbook prototype pollution vector — unflatten({ "__proto__.isAdmin": true }) without the guard writes onto Object.prototype, and every object in the process suddenly has isAdmin. This exact bug has CVEs in lodash (_.set), jQuery, and dozens of packages. The guard above (skip __proto__/constructor/prototype segments) or building with null-prototype objects (the Object.groupBy trick) closes it. Mentioning pollution unprompted, with the lodash CVE as the citation, is the strongest single sentence available in this question.

Edge cases interviewers probe

  • Keys containing the delimiter{ "a.b": 1 } (a literal dot in one key) flattens identically to { a: { b: 1 } }: the round trip is ambiguous. Fixes: escape delimiters, choose an unlikely delimiter, or store paths as arrays internally (the lossless representation — it's what lodash's get/set accept). Knowing flat-string paths are fundamentally lossy is the theory point.
  • Empty containers — the keepEmpty policy above.
  • Numeric object keys vs arrays{ a: { 0: "x" } } and { a: ["x"] } flatten identically; unflatten's digit-heuristic will pick the array. Lossy again — same root cause, worth grouping with the delimiter issue as "flattening erases type distinctions."
  • Cyclesa.self = a recurses forever; the ancestor-set guard from deep clone applies if inputs aren't trusted to be trees.
  • Sparse arrays — holes produce no keys (Object.keys skips them)… but our value.map((_, i) => i) visits them as undefined leaves. Two defensible behaviors; the sparse-array weirdness strikes again.

Common mistakes

  • No pollution guard in unflatten (the security miss).
  • Treating null as a container (typeof null === "object") and crashing on Object.keys(null).
  • Recursing into Dates/class instances and emitting garbage paths.
  • Building paths with + concatenation including a leading delimiter (.a.b) — the path ? ... : key seed handles the root.
  • Claiming the round trip is lossless without qualifying the three lossy cases (delimiter collision, empty containers, array-vs-numeric-keys).

Follow-up questions

  • "Implement get(obj, "a.b.0") and set using these ideas." — the same segment-walk without the full transform; set shares unflatten's container-creation logic (and its pollution guard!) — this is literally lodash _.get/_.set.
  • "Flatten to depth N only?" — thread a depth parameter, stop recursing and emit the subtree as the leaf — mirrors flat(depth).
  • "Diff two objects using flatten." — flatten both, compare key sets and values (deep equal per leaf is now string comparison) — a genuinely useful trick for config drift and form dirty-checking.
  • "How does Firestore's dot-path update differ from replacing the object?" — path writes merge at the leaf; object writes replace the subtree — the exact reason flat paths exist in database APIs; great systems tie-in.
  • "Type the flattened result in TypeScript?" — recursive template-literal types can compute "user.address.city" unions from the object type — at the edge of practical, and naming Paths<T>-style types (as in react-hook-form's typings) shows you've seen it done for real.

  • Use a Proxy to support arr[-1] like Python — a practical introduction to Proxy traps.

  • Deep Flatten

    beginner

    Flatten arbitrarily nested arrays recursively and iteratively — then compare with Array.prototype.flat.

  • Custom Deep Equal

    intermediate

    Structural equality for nested data: type checks, key comparison, and recursion done right.

  • Recursively clone nested objects and arrays, handling cycles with a WeakMap.