cf.completefrontend
ALGORITHMS IN FRONTEND WORK · FOUNDATION

Flattening nested data

Turn nested arrays or objects into a single flat level, going only as deep as you ask.

Time O(n)Space O(n)6 practice problemsStep-through picture

The idea

Data from APIs is often nested: arrays inside arrays, or settings objects inside objects. Many tasks want it flat. A chart wants one list of points, a form wants keys like address.city, and a table wants one row per item.

Array.prototype.flat(depth) flattens arrays: [1, [2, [3]]].flat(1) gives [1, 2, [3]], and .flat(Infinity) flattens everything. The idea is recursive: for each item, if it is an array and you may still go deeper, flatten it with depth - 1 and add its items; otherwise add the item as it is.

Objects flatten the same way, but each value's path becomes its key: { a: { b: 1 } } becomes { "a.b": 1 }. The path grows by one key at each level, which is passed down as prefix.

An everyday example

Unpacking boxes inside boxes

You get a delivery: a big box holding items and smaller boxes, some with even smaller boxes inside. You are told to unpack only two levels deep. You open the big box and lay out each item. When you find a box, you open it and lay out its contents too, counting one level down. A box you find at the deepest allowed level goes on the table unopened.

How the example maps to the code
In the exampleIn the code
A boxA nested array, Array.isArray(item)
How many more levels you may opendepth
Opening a box one level downflatten(item, depth - 1)
Laying an item on the tableresult.push(item)
The label path on a nested objectprefix joined with key, like a.b

From the slow way to flattening nested data

  1. Start with one level. Loop over the items: if an item is an array, push each of its items; otherwise push the item. That's exactly flat(1). For two levels, you'd put a second loop inside the first, and for depth levels you'd need depth loops nested inside each other. You can't write a different function for every depth, and Infinity can't be written at all.
  2. Look at the inner job. Flattening an inner array by "the levels that are left" is the same problem, only one level smaller. So the function can call itself with depth - 1. When depth reaches 0, nothing more may be opened, so arrays are pushed whole. That's the base case that stops the recursion.
  3. Each call builds its own result and returns it, and the caller copies those items into its own result, in order. The order of the output matches a left-to-right reading of the input, which is what Array.prototype.flat promises.
  4. Objects flatten the same way, with one change: instead of counting depth down, each call passes the key path it has built so far, prefix, and adds one key per level. A value that isn't a non-empty object is stored as out[path]. These two recursions are flattening nested data.

How it works

  1. Make an empty result list.
  2. For each item: if it is an array and depth > 0, flatten it with depth - 1 and add all its items to result.
  3. Otherwise push item as it is. That includes arrays when depth has reached 0.
  4. Return result.
  5. For objects, walk each [key, child]. Build path from prefix and key. A non-empty object child is flattened with path as the new prefix; anything else is stored as out[path] = child.

Watch it run

This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.

Loading the picture…
See every step at onceThe same run, laid out side by side
Drawing every step…

The code

Point at a note to light up the lines it explains.

  1. function flatten(items, depth = 1) {
  2. const result = [];
  3. for (const item of items) {
  4. if (Array.isArray(item) && depth > 0) {
  5. for (const inner of flatten(item, depth - 1)) result.push(inner);
  6. } else {
  7. result.push(item);
  8. }
  9. }
  10. return result;
  11. }
  12. function flattenObject(value, prefix = "", out = {}) {
  13. for (const [key, child] of Object.entries(value)) {
  14. const path = prefix ? `${prefix}.${key}` : key;
  15. if (child !== null && typeof child === "object" && Object.keys(child).length > 0) {
  16. flattenObject(child, path, out);
  17. } else {
  18. out[path] = child;
  19. }
  20. }
  21. return out;
  22. }

How fast it is

TimeO(n)
Extra memoryO(n)

Each value is visited once and copied into the result once. The recursion stack is at most as deep as the nesting.

  • Best caseO(n)

    A list that is already flat: each item is pushed once and no inner call is made.

  • Average caseO(n)

    Typical API data, like a list of small groups: each item is copied once per level it rises, which is a small, fixed number of times.

  • Worst caseO(n^2)

    A staircase like [1, [2, [3, [4, ...]]]] with depth = Infinity: the item at level k is copied k times. For 1,000 items that's 500,500 pushes. Passing one shared result down fixes this.

Common mistakes

  • Using result.push(...flatten(item)) on huge arrays. Spreading passes every item as an argument, which can exceed the engine's limit. Push in a loop or use concat.
  • Forgetting the depth limit and always flattening everything. flat() with no argument only goes one level.
  • Treating null as an object. typeof null is "object", so check child !== null first.
  • Losing empty objects and arrays when flattening keys. Decide whether they become values or disappear, and do it on purpose.

When to use it

Look for these clues in a problem:

  • The input is nested arrays or objects and the output should be one level.
  • The question gives a depth limit, like Array.prototype.flat(depth).
  • Nested keys must become paths like user.address.city, or the reverse.
  • A tree of comments, menus, or folders must become a list with each item's depth.

Check yourself

Answer each one in your head first, then open it.

What does flatten([1, [2, [3]]], 1) return?

[1, 2, [3]]. The outer array's items are opened one level, so [2, [3]] is opened, but [3] sits one level deeper and stays whole.

Why does depth = Infinity work without a special case?

Infinity - 1 is still Infinity, so depth > 0 is true at every level and every array is opened.

In a staircase of 1,000 items, [1, [2, [... [1000]]]], how many times is 1000 pushed with depth = Infinity?

1,000 times: once in the innermost call, and once more in each of the 999 calls above it as their results are copied up.

What does flattenObject({ a: { b: {} } }) return?

{ "a.b": {} }. a is a non-empty object, so it's opened, but b is empty, so it's stored as a value under its full path instead of disappearing.

Practice problems

Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.

Go further

You don't need these to start practicing. Open one when you want more.

Why it works

The reasoning that makes it always right

The promise is: flatten(items, d) returns the items in order, with every array nested up to d levels deep opened. For d = 0 the loop pushes every item unchanged, so the promise holds. For d > 0, a non-array item is pushed as is, and an array item is replaced by flatten(item, d - 1), which by the same promise opens d - 1 more levels inside it. Together with opening item itself, that's d levels.

Order is kept because the loop goes through items left to right, and each inner result is copied in, in its own order, before the loop moves on. The recursion ends because each call goes one level deeper into finite data. With depth = Infinity, Infinity - 1 is still Infinity, so the data's own depth is what stops it; data that contains itself would never stop.

For flattenObject, the rule is that prefix is always the dotted path of keys from the top to the current object. Each call adds exactly one key with ${prefix}.${key}, so every leaf is stored under its full path. A non-empty object is opened, and anything else, including null and empty objects, becomes a value. That way no leaf is lost.

Going deeper

3 more ideas

Why the depth goes down by one

Each call handles one level of nesting. Passing depth - 1 to the inner call means the inner arrays get one level less of unpacking. When depth is 0, arrays are kept whole, which is exactly what flat(depth) promises.

How fast it is

Every value is visited once, so the time is O(n) for n values in total, including the arrays themselves. The recursion goes as deep as depth or the nesting, whichever is smaller, and the result holds up to n items.

Very deep data and empty objects

Recursion can overflow the call stack on data nested thousands of levels deep. An explicit stack of [item, depth] pairs avoids that. For objects, decide what an empty object or array becomes: this version stores it as a value, so { a: {} } becomes { "a": {} } instead of disappearing.

Work it by hand

Every step in a table, with why

Flatten a list with an empty array, a deep branch, and a doubly wrapped item, two levels deep. One array is deeper than the limit and stays whole.

flatten(["x", [], ["y", ["z", ["w"]]], [["v"]]], 2)

StepdepthitemDecisionresult of this callWhy
12"x"push["x"]Not an array, so it is pushed as it is.
22[]open with depth 1["x"]An array and depth > 0, so it is opened. The inner call has no items and returns [], so nothing is added.
32["y", ["z", ["w"]]]open with depth 1["x"]Opened. This call now waits while the inner call works.
41"y"push["y"]The inner call has its own result.
51["z", ["w"]]open with depth 0["y"]depth is still 1, so this array is opened too, one level less deep.
60"z"push["z"]The deepest call. "z" is pushed.
70["w"]depth is 0: push whole["z", ["w"]]An array, but no levels are left to open, so it stays wrapped.
81(inner call done)copy 2 items in["y", "z", ["w"]]The depth 1 call copies "z" and ["w"] into its own result.
92(inner call done)copy 3 items in["x", "y", "z", ["w"]]The top call copies them again into the final result.
102[["v"]]open with depth 1["x", "y", "z", ["w"]]The last top-level item is opened.
111["v"]open with depth 0[]There's still a level left, so the inner wrapper opens too.
120"v"push["v"]"v" is pushed in the deepest call and then copied up through both calls above it.

It returns ["x", "y", "z", ["w"], "v"]. ["w"] stayed wrapped because it sat 3 levels deep, one more than depth allowed; the empty array simply disappeared.

Compared with other ways

When to pick something else
ApproachTimeExtra memoryChoose it when
Flattening nested dataO(n)O(n)You need control over the rules, like objects to dotted keys or a custom test for what to open. Deep staircases cost more, because items are copied once per level.
Array.prototype.flat(depth)O(n)O(n)Plain arrays in real code. It's built in, takes the same depth, and skips holes.
Explicit stackO(n)O(n)The data may be nested thousands of levels deep, where recursion would overflow the call stack.
Lazy generatorO(n)O(d)You may stop early, or the data is too big to build a full result array; the extra memory is only one paused call per level d.

Extra memory: O(n) for the result, plus one call per level of nesting opened. Each call's own result is temporary.

How the steps grow

1101001k10k100k1M116324864input size n
Steps as the input grows, for Flattening nested data and the other approaches, on a scale where each line up is 10 times more steps.
  • Flattening nested data, Array.prototype.flat(depth), Explicit stack, Lazy generator O(n)
About how many steps for bigger inputs
Approachn = 10n = 1,000n = 1,000,000
Flattening nested data101,0001 million
Array.prototype.flat(depth)101,0001 million
Explicit stack101,0001 million
Lazy generator101,0001 million

Inputs that need care

6 tricky inputs
depth = 0, or a negative depth like -1
depth > 0 is false, so nothing is opened and you get a shallow copy of the list. Native flat treats negative depths the same way.
depth = Infinity
Infinity - 1 is Infinity, so every level is opened: flatten([1, [2, [3]]], Infinity) gives [1, 2, 3].
A sparse array: flatten([[1, , 2]])
for...of reads the hole as undefined, so it gives [1, undefined, 2]. Native [[1, , 2]].flat() skips holes and gives [1, 2]. Check i in item in an index loop to match it.
Strings and array-like objects, like "ab" or a NodeList
Array.isArray is false for both, so they are pushed whole. That's usually right for strings; convert a NodeList with Array.from first if you want it opened.
An array that contains itself, with depth = Infinity
The recursion never reaches the bottom and throws RangeError. Keep a seen set of arrays on the current path if the data can't be trusted.
Key clashes: flattenObject({ "a.b": 1, a: { b: 2 } })
Both produce the path "a.b", so the later one wins and the result is { "a.b": 2 }. Use a separator that can't appear in keys, or escape dots, if keys may contain them.

Variations and follow-ups

Twists interviewers add

One shared result

Pass result into the inner call and push straight into it, instead of returning a new array each time. Every item is pushed exactly once, so even the deepest staircase is O(n).

An explicit stack

Push [item, depth] pairs onto a stack, starting with the top-level items in reverse. Pop a pair: an array with depth > 0 has its items pushed in reverse with depth - 1; anything else goes into the result. No recursion means no call-stack limit on very deep data.

Unflattening

The reverse job turns { "address.city": "Oslo" } back into { address: { city: "Oslo" } }. Split each key on . and create objects along the path, or arrays when the next key is a number, like items.0.name in form libraries.

A lazy generator

function* flatten(items, depth) can yield items and yield* inner calls. Nothing is copied between levels, and the caller can stop early, for example after finding the first 10 matching items.

In an interview

Spot it, explain it, answer follow-ups
  • Spot it from "flatten", "nested arrays", a depth argument, "dot paths like user.address.city", or a tree that must become a list.
  • Explain the recursion in one sentence: "for each item, if it's an array and I may go deeper, flatten it one level less and add its items; otherwise add it". Then name the base case, depth === 0.
  • Test by hand: depth 0, depth 1 with a doubly nested item, Infinity, an empty inner array, and an item that is a string.
  • Expect the follow-ups "do it without recursion" (an explicit stack of [item, depth] pairs) and "write the reverse, unflatten". For objects, ask what empty objects and null should become.

Where frontend code uses it

The same idea in real UI code
  • Array.prototype.flat and flatMap are built into JavaScript; flatMap is common in React code that renders a list of groups as one list.
  • Form libraries like React Hook Form and Formik name nested fields with paths like address.city and items.0.name, and translation files are often flattened to dotted keys.
  • Comment threads, file trees, and nested menus are flattened into one list with a depth per item so they can be rendered by a virtualized list.

Related algorithms