Flattening nested data
Turn nested arrays or objects into a single flat level, going only as deep as you ask.
O(n)Space O(n)6 practice problemsStep-through pictureThe 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.
| In the example | In the code |
|---|---|
| A box | A nested array, Array.isArray(item) |
| How many more levels you may open | depth |
| Opening a box one level down | flatten(item, depth - 1) |
| Laying an item on the table | result.push(item) |
| The label path on a nested object | prefix joined with key, like a.b |
From the slow way to flattening nested data
- 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 fordepthlevels you'd needdepthloops nested inside each other. You can't write a different function for every depth, andInfinitycan't be written at all. - 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. Whendepthreaches0, nothing more may be opened, so arrays are pushed whole. That's the base case that stops the recursion. - Each call builds its own
resultand returns it, and the caller copies those items into its ownresult, in order. The order of the output matches a left-to-right reading of the input, which is whatArray.prototype.flatpromises. - Objects flatten the same way, with one change: instead of counting
depthdown, 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 asout[path]. These two recursions are flattening nested data.
How it works
- Make an empty
resultlist. - For each
item: if it is an array anddepth > 0, flatten it withdepth - 1and add all its items toresult. - Otherwise push
itemas it is. That includes arrays whendepthhas reached0. - Return
result. - For objects, walk each
[key, child]. Buildpathfromprefixandkey. A non-empty object child is flattened withpathas the new prefix; anything else is stored asout[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.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function flatten(items, depth = 1) {const result = [];for (const item of items) {if (Array.isArray(item) && depth > 0) {for (const inner of flatten(item, depth - 1)) result.push(inner);} else {result.push(item);}}return result;}function flattenObject(value, prefix = "", out = {}) {for (const [key, child] of Object.entries(value)) {const path = prefix ? `${prefix}.${key}` : key;if (child !== null && typeof child === "object" && Object.keys(child).length > 0) {flattenObject(child, path, out);} else {out[path] = child;}}return out;}
How fast it is
| Time | O(n) |
|---|---|
| Extra memory | O(n) |
Each value is visited once and copied into the result once. The recursion stack is at most as deep as the nesting.
- Best case
O(n)A list that is already flat: each item is pushed once and no inner call is made.
- Average case
O(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 case
O(n^2)A staircase like
[1, [2, [3, [4, ...]]]]withdepth = Infinity: the item at levelkis copiedktimes. For 1,000 items that's 500,500 pushes. Passing one sharedresultdown 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 useconcat. - Forgetting the depth limit and always flattening everything.
flat()with no argument only goes one level. - Treating
nullas an object.typeof nullis"object", so checkchild !== nullfirst. - 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.
0 of 6 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo 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)
| Step | depth | item | Decision | result of this call | Why |
|---|---|---|---|---|---|
| 1 | 2 | "x" | push | ["x"] | Not an array, so it is pushed as it is. |
| 2 | 2 | [] | 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. |
| 3 | 2 | ["y", ["z", ["w"]]] | open with depth 1 | ["x"] | Opened. This call now waits while the inner call works. |
| 4 | 1 | "y" | push | ["y"] | The inner call has its own result. |
| 5 | 1 | ["z", ["w"]] | open with depth 0 | ["y"] | depth is still 1, so this array is opened too, one level less deep. |
| 6 | 0 | "z" | push | ["z"] | The deepest call. "z" is pushed. |
| 7 | 0 | ["w"] | depth is 0: push whole | ["z", ["w"]] | An array, but no levels are left to open, so it stays wrapped. |
| 8 | 1 | (inner call done) | copy 2 items in | ["y", "z", ["w"]] | The depth 1 call copies "z" and ["w"] into its own result. |
| 9 | 2 | (inner call done) | copy 3 items in | ["x", "y", "z", ["w"]] | The top call copies them again into the final result. |
| 10 | 2 | [["v"]] | open with depth 1 | ["x", "y", "z", ["w"]] | The last top-level item is opened. |
| 11 | 1 | ["v"] | open with depth 0 | [] | There's still a level left, so the inner wrapper opens too. |
| 12 | 0 | "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
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Flattening nested data | O(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 stack | O(n) | O(n) | The data may be nested thousands of levels deep, where recursion would overflow the call stack. |
| Lazy generator | O(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
- Flattening nested data,
Array.prototype.flat(depth), Explicit stack, Lazy generatorO(n)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Flattening nested data | 10 | 1,000 | 1 million |
Array.prototype.flat(depth) | 10 | 1,000 | 1 million |
| Explicit stack | 10 | 1,000 | 1 million |
| Lazy generator | 10 | 1,000 | 1 million |
Inputs that need care
6 tricky inputs
depth = 0, or a negative depth like-1depth > 0is false, so nothing is opened and you get a shallow copy of the list. Nativeflattreats negative depths the same way.depth = InfinityInfinity - 1isInfinity, so every level is opened:flatten([1, [2, [3]]], Infinity)gives[1, 2, 3].- A sparse array:
flatten([[1, , 2]]) for...ofreads the hole asundefined, so it gives[1, undefined, 2]. Native[[1, , 2]].flat()skips holes and gives[1, 2]. Checki in itemin an index loop to match it.- Strings and array-like objects, like
"ab"or aNodeList Array.isArrayis false for both, so they are pushed whole. That's usually right for strings; convert aNodeListwithArray.fromfirst if you want it opened.- An array that contains itself, with
depth = Infinity - The recursion never reaches the bottom and throws
RangeError. Keep aseenset 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
depthargument, "dot paths likeuser.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:
depth0,depth1 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 andnullshould become.
Where frontend code uses it
The same idea in real UI code
Array.prototype.flatandflatMapare built into JavaScript;flatMapis 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.cityanditems.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.