A coded interview example, in the hiring sense, is a technical round shown end to end rather than as a finished solution. This one walks a single question, implement
flat(array, depth)without using the native method, across a 35-minute slot: the clarifying questions worth asking in the first five minutes, a working recursive version at minute 15, a hole-aware fix at minute 21, an explicit-stack version at minute 24, and Jest tests from minute 28, including the matcher that quietly passes a broken implementation, and the exact shape of input where it does. The same four-phase structure then runs again on a typeahead component, because the frontend round is often a UI question and often in a framework you did not pick.
Two Things People Mean by "Coded Interview"
Search this phrase and you land in one of two different rooms, so here is the door to the other one first.
In qualitative research, coding an interview means labelling segments of a transcript with short tags so patterns can be compared across participants. In vivo coding uses the participant's exact words or phrases as the codes themselves, which keeps the analysis anchored to how people actually spoke rather than to your paraphrase of it. ATLAS.ti's guide to coding interviews covers the mechanics. If you want transcripts to practise on, the SAGE companion site for Johnny Saldaña's The Coding Manual for Qualitative Researchers (Third Edition) hosts three of them, Brenda, Ms. D and Sam, published as coding exercises. Those two links are the fastest route out of here if that is what you came for.
The other room is hiring. Someone hands you a problem, you write code while a stranger watches, and what you said while producing the code is graded alongside the code itself. That is the sense used from here on, and specifically the frontend version of it, where the question is as likely to be a component as a tree traversal.
The two meanings collide in search because both are genuinely called "coding an interview". Neither group is searching wrong.
The Setup: One Question, 35 Minutes, No Autocomplete
Meta's careers blog describes the 45-minute interview as "Introductions (5), 35 minutes of coding, Questions (5)". That is the shape this walkthrough uses: one question, 35 minutes, one shared editor, an interviewer watching you type. Worth knowing before you plan around it: Meta also says that for the initial technical screen "you'll solve two coding problems focused on computer science fundamentals like algorithms, data structures, recursions and binary trees". Two problems in the same 35 minutes means every phase below compresses to roughly half. The phases stay; the minutes shrink.
The question: implement flat(array, depth) without using native Array.prototype.flat.
It was picked for three reasons. It has real ambiguity, and the ambiguity is resolvable by asking rather than guessing. It has a published specification you can be judged against, since flat() is an ECMAScript 2019 addition that MDN marks Baseline widely available and available across browsers since January 2020, which means the interviewer can compare your output to the real thing in one line. And it has a natural optimisation that is not a trick: the obvious recursive solution has a failure mode, and there is a standard way out of it.
Two editors show up in real screens, and they demand different habits. The first is a plain pad. Meta tells candidates to practise in a plain text editor because "during the interview, you'll write your code in a similar environment without syntax highlighting or auto-completion", which is the environment where you find a missing brace by squinting rather than by red underline. The second is a multi-file framework pad. CoderPad's multi-file pads cover React, Vue, Angular, HTML/CSS/JS and Svelte among others, render the app's UI in a pane on the right, and give you an interactive shell where you can install packages with npm. Same 35 minutes, completely different muscle memory. If you have only ever practised in one, the other will cost you the first five minutes. There is more on structuring the JavaScript side of this in the JavaScript coding interview guide.
Minutes 0–5: The Clarifying Questions That Change the Code
A load-bearing question is one whose answer changes a specific line of your implementation. A ritual question is one you ask because you read that asking questions scores points; it burns clock and the code you write afterwards is identical to the code you would have written before. Interviewers can tell the difference, because they watch what you do with the answer.
For flat, five questions are load-bearing.
"Is there a default depth if the caller passes nothing?" Native defaults to 1, so [0, 1, [2, [3, [4, 5]]]].flat() returns [0, 1, 2, [3, [4, 5]]]. That answer becomes the default parameter on line 1.
"Should Infinity work as a depth?" It changes how you coerce the argument, and it rules out a whole family of one-liners.
"What should happen to holes in a sparse array?" Native removes empty slots: [1, 2, , 4, 5].flat() returns [1, 2, 4, 5]. This answer adds a line inside the loop and is the single highest-value question on the list.
"New array, or mutate the input?" Native is a copying method. That answer decides whether there is an out array at all.
"May I call native flat internally?" Almost always no, and asking makes it explicit rather than discovering at minute 30 that your flatMap shortcut was disqualifying.
Here is the exchange, compressed:
You: Default depth if none is passed? Interviewer: Match the native method. You: So 1. Does
Infinityneed to work? Interviewer: Yes, assume callers use it. You: And sparse arrays, holes in the input? Native drops them. Interviewer: Then drop them. You: Returning a new array, input untouched? Interviewer: Yes.
Contrast that with "can I use helper functions", "should I write this in JavaScript", "can I assume the input is an array". The third one sounds load-bearing and is not, because whatever the answer, you either add a guard clause or you do not, and the guard is a one-liner you add at the end.
Now write the contract down, in the pad, before any implementation. Two lines is enough:
// flat(input, depth = 1) -> new array; input untouched
// depth: 1 default, Infinity allowed, 0 or negative copies only
// holes dropped at every level that gets flattened; real undefined kept
Those comments are the thing you check against at minute 30. Meta's blog also notes that "if you are stuck, ask questions", because the interviewer knows the problem well enough to unblock you. Questions are a recovery tool later, not only an opening ritual.
Minutes 5–15: Write the Version That Works
Meta's advice here is explicit: "if you can't find a better solution in a reasonable time, start writing a working solution, then iterate and improve it as you go." Paired with "think out loud. We pay a lot of attention to the way you solve problems, which can be as important as having the right answer." So you narrate the shape before typing it: an output array, a loop over indexes, Array.isArray as the recursion test, depth decremented on the way down.
function flat(arr, depth = 1) {
const out = [];
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
if (Array.isArray(value) && depth > 0) {
out.push(...flat(value, depth - 1));
} else {
out.push(value);
}
}
return out;
}
console.log(JSON.stringify(flat([0, 1, [2, [3, [4, 5]]]])));
console.log(JSON.stringify(flat([0, 1, [2, [3, [4, 5]]]], 2)));
console.log(JSON.stringify(flat([0, 1, [2, [3, [4, 5]]]], Infinity)));
[0,1,2,[3,[4,5]]]
[0,1,2,3,[4,5]]
[0,1,2,3,4,5]
That is minute 15, and it matches MDN's documented behaviour for depths 1, 2 and Infinity.
Then say the complexity without being asked. Every element at every level is visited once, so the visiting cost is linear in the total number of elements. The spread is the caveat worth naming: out.push(...flat(value, depth - 1)) copies each recursive result into its parent, so an element nested k levels deep is copied k times, and the honest worst case is O(n·d) for total elements n and nesting depth d. Passing a shared output array down instead of returning a new one removes the repeated copying. Spreading also passes one argument per element, which very large arrays can choke on.
Then name the thing you know is still wrong, before the interviewer finds it: "this handles holes incorrectly, out.push(arr[i]) on an empty slot pushes undefined where native would skip the index. I'll fix that next." Saying it costs eight seconds and converts a missed edge case into a scheduled one.
Minutes 15–25: The Edge Cases That Move the Score
Start with the hole bug, because it is the one this problem is really about.
function flatV1(arr, depth = 1) {
const out = [];
for (let i = 0; i < arr.length; i++) {
const value = arr[i];
if (Array.isArray(value) && depth > 0) out.push(...flatV1(value, depth - 1));
else out.push(value);
}
return out;
}
console.log(flatV1([1, , 3]).length, [1, , 3].flat().length);
console.log([1, undefined, 3].flat().length, [1, , 3].flat().length);
console.log(1 in [1, , 3], 1 in [1, undefined, 3]);
3 2
3 2
false true
The second line is why the obvious fix is wrong. Native flat() keeps a real undefined element and drops a hole, so arr[i] !== undefined cannot distinguish them. The in operator can: a hole is an index that does not exist.
Version two, at minute 21, adds one guard line and normalises the depth argument:
function flat(arr, depth = 1) {
const out = [];
const d = Math.trunc(depth) || 0;
for (let i = 0; i < arr.length; i++) {
if (!(i in arr)) continue;
const value = arr[i];
if (Array.isArray(value) && d > 0) {
out.push(...flat(value, d - 1));
} else {
out.push(value);
}
}
return out;
}
console.log(flat([1, , 3]).length);
console.log(JSON.stringify(flat([1, , 3, ["a", , "c"]])));
console.log(JSON.stringify(flat([0, 1, [2, [3, [4, 5]]]], Infinity)));
2
[1,3,"a","c"]
[0,1,2,3,4,5]
The second line matches MDN's documented result for [1, , 3, ["a", , "c"]].flat(). The rule MDN states is worth repeating out loud in the room: holes are removed at every level that actually gets flattened, and holes inside arrays that stay nested are preserved along with those arrays.
Math.trunc rather than a bitwise trick, because the bitwise version silently destroys the one depth value you were told to support:
console.log(Infinity | 0);
console.log(Math.trunc(Infinity));
console.log(Math.trunc(1.9), Math.trunc(-1.9));
0
Infinity
1 -1
Math.trunc(depth) || 0 handles the rest of the argument space in one line: 0 stays 0, a negative depth fails the d > 0 test and copies without flattening, a fractional depth truncates, and a depth that coerces to NaN becomes 0. Numeric strings coerce rather than fail, which is what native does too: [1, [2]].flat('2') flattens two levels. Non-array input is a one-line guard at the top, and this is the moment to ask whether it should throw or return an empty array rather than to decide unilaterally.
Now the case that motivates version three, at minute 24. The recursion nests one call frame per level of the input, so input nested deeply enough exhausts the call stack. Converting the recursion to an explicit stack moves that growth onto the heap. The ordering detail is the part candidates get wrong: children have to be pushed in reverse so they pop in source order.
function flatRecursive(arr, depth = 1) {
const out = [];
const d = Math.trunc(depth) || 0;
for (let i = 0; i < arr.length; i++) {
if (!(i in arr)) continue;
const v = arr[i];
if (Array.isArray(v) && d > 0) out.push(...flatRecursive(v, d - 1));
else out.push(v);
}
return out;
}
function flatIterative(arr, depth = 1) {
const out = [];
const start = Math.trunc(depth) || 0;
const stack = [];
for (let i = arr.length - 1; i >= 0; i--) {
if (i in arr) stack.push([arr[i], start]);
}
while (stack.length > 0) {
const [v, d] = stack.pop();
if (Array.isArray(v) && d > 0) {
for (let i = v.length - 1; i >= 0; i--) {
if (i in v) stack.push([v[i], d - 1]);
}
} else {
out.push(v);
}
}
return out;
}
console.log(JSON.stringify(flatIterative([0, 1, [2, [3, [4, 5]]]])));
console.log(JSON.stringify(flatIterative([0, 1, [2, [3, [4, 5]]]], Infinity)));
let deep = [1];
for (let i = 0; i < 100000; i++) deep = [deep];
try {
flatRecursive(deep, Infinity);
console.log('recursive: returned');
} catch (err) {
console.log('recursive: ' + err.name);
}
console.log('iterative: ' + JSON.stringify(flatIterative(deep, Infinity)));
[0,1,2,[3,[4,5]]]
[0,1,2,3,4,5]
recursive: RangeError
iterative: [1]
Say the trade-off plainly rather than declaring a winner. The recursive version is shorter, reads closer to the specification, and is the better answer for realistic input. The iterative version exists for input whose nesting depth you do not control. If the interviewer's follow-up is "what if the array is 100,000 levels deep", version three is the answer. If there is no such follow-up, offering it in one sentence and keeping version two on screen is the stronger move.
Minutes 25–35: Verifying Out Loud, and the toEqual Trap
Verification starts by naming the cases, not by rerunning the happy path. Five, spoken aloud, then traced:
- Nested input at default depth, which must stop after one level.
- Depth 2 on the same input.
Infinityon input deeper than 2.- Sparse input, where the output length must shrink.
- A member that is not an array at all, including a real
undefined.
Trace one by hand while pointing at the loop, then write them as tests. Assuming the pad runs Jest:
test('flat matches native behaviour', () => {
expect(flat([0, 1, [2, [3, [4, 5]]]])).toStrictEqual([0, 1, 2, [3, [4, 5]]]);
expect(flat([0, 1, [2, [3, [4, 5]]]], 2)).toStrictEqual([0, 1, 2, 3, [4, 5]]);
expect(flat([1, [2, [3, [4]]]], Infinity)).toStrictEqual([1, 2, 3, 4]);
const trailing = flat([1, 2, ,]);
expect(trailing).toStrictEqual([1, 2]);
const holes = flat([1, , 3]);
expect(holes).toStrictEqual([1, 3]);
expect(holes.length).toBe(2);
expect(flat([1, undefined, 3])).toStrictEqual([1, undefined, 3]);
});
The matcher is doing real work on the trailing hole, and swapping it for the more familiar one hides the bug you spent minute 21 fixing. [1, 2, ,] is length 3 — the last comma leaves an empty slot — so version one returns [1, 2, undefined] for it, and expect([1, 2, undefined]).toEqual([1, 2]) passes: toEqual skips the array length check and the two key sets line up once the undefined item is ignored. The mid-array hole is not the case that shows this off. [1, undefined, 3] shifts every later index, so the key sets stop lining up and toEqual fails it as surely as toStrictEqual does, which is also why the length assertion catches that one whichever matcher you use. Jest documents that toEqual ignores object keys with undefined properties, ignores undefined array items and ignores array sparseness, so [, 1] equals [undefined, 1] under toEqual while toStrictEqual treats them as different. Two arrays that toEqual cannot tell apart flatten to different results, and neither serialising them nor comparing their values sees the difference — only index presence does:
console.log(JSON.stringify([, 1]) === JSON.stringify([undefined, 1]));
console.log(0 in [, 1], 0 in [undefined, 1]);
console.log([, 1].flat(), [undefined, 1].flat());
true
false true
[ 1 ] [ undefined, 1 ]
The last quotable line from Meta's blog belongs to this phase: "find and fix the bugs by yourself. Don't wait for the interviewer to find them for you." A failing test you wrote, diagnosed and fixed on the clock reads better than a clean run of three cases you chose because you knew they would pass.
The Same 35 Minutes on a UI Component Question
Run the identical four phases on a typeahead. The structure survives; the content of each phase changes.
Minutes 0–5, the load-bearing questions. Who owns the input value, the parent or the component, which is the controlled versus uncontrolled decision and changes the prop signature. Does keyboard navigation need arrow keys, Enter and Escape, and does the listbox need ARIA roles and aria-activedescendant, which changes the markup and the state you keep. What happens to a request that is still in flight when the user types again. What happens on empty results and on error. Compare those to the ritual version: "should it look good?" produces no line of code.
Minutes 5–15, the version that works. Input, state, fetch on change, render a list. No debounce, no cancellation, no keyboard. In React that is about twenty lines:
function Typeahead() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
fetch(`/search?q=${encodeURIComponent(query)}`)
.then((res) => res.json())
.then(setResults);
}, [query]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((r) => (
<li key={r.id}>{r.label}</li>
))}
</ul>
</>
);
}
Typing ha puts one request in flight per keystroke and paints whatever comes back, in whatever order it comes back. It renders and it searches, and you say out loud that both the debounce and the cancellation are coming.
Minutes 15–25, the edge cases. Debounce the input first. A second piece of state holds the settled query, a timer copies query into it 300ms after the last keystroke, and the cleanup clears that timer every time query changes, so only the last keystroke in a 300ms window ever reaches the fetch:
const [debounced, setDebounced] = useState('');
useEffect(() => {
const id = setTimeout(() => setDebounced(query), 300);
return () => clearTimeout(id);
}, [query]);
Point the fetch effect at debounced instead of query and typing hask sends one request rather than four. Then cancel the stale ones. AbortController exposes a read-only signal and an abort() method; pass controller.signal to fetch, call abort() when a newer query starts, and the old promise rejects with an AbortError you catch and discard.
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
return await res.json();
} catch (err) {
if (err.name === 'AbortError') return null;
throw err;
}
}
Minutes 25–35, verification. You cannot verify a 300ms debounce with a stopwatch, and a real timer makes the test slow and flaky. Jest's fake timers replace the clock: jest.useFakeTimers(), jest.advanceTimersByTime(msToRun), jest.runAllTimers(), jest.runOnlyPendingTimers(), jest.clearAllTimers() and jest.useRealTimers() to put it back. The assertion shape, with your framework's own typing helper in place of typeInto:
jest.useFakeTimers();
typeInto(input, 'c');
typeInto(input, 'cl');
typeInto(input, 'clo');
jest.advanceTimersByTime(299);
expect(fetchSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
jest.useRealTimers();
The framework is often your choice, and sometimes theirs. That is precisely why the phase structure has to be portable: the contract, the working version, the edge cases and the verification are the same four moves whether you are holding React hooks, Vue's reactivity, Angular signals or plain TypeScript with addEventListener. Only the syntax of each move changes. React interview questions covers what shifts when the round is explicitly React.
Running This Yourself: A Self-Scored Practice Loop
Reading someone else's transcript teaches you the shape. Grading your own recording teaches you where you lose minutes.
The loop: set a 35-minute timer and run the same four phases. Start a screen recording with your microphone on, because narrating to nobody is the skill being trained. Write the contract as comments before any implementation. Force yourself to reach a running version by minute 15 even when you can see the better approach, then improve it. Finish by writing tests and actually running them. Then watch the recording at 2x with this sheet:
- Contract stated before code? Did the pad contain a written signature, default and edge-case list in the first five minutes, or did you start typing the loop?
- Working code by minute 15? Something that runs and produces output, however naive.
- Edge cases raised unprompted? Count how many you named before anyone asked, and how many were load-bearing rather than decorative.
- Who found the first bug, you or the runner? Both beat "nobody, and it shipped broken".
Four honest answers per run, tracked across ten runs, will show you which phase you skip under pressure. The first and the fourth are the ones that tend to go first under pressure.
The environment matters more than it seems, because a dry run in a plain text file never tells you a test failed. Practising somewhere that runs the tests closes that gap: UIReady's questions run in a Sandpack workspace, a toolkit for live-running code editing powered by the bundler used on CodeSandbox, executing in the browser against real Jest tests. The catalogue lists 660+ questions, 470+ of them free, with many authored across React, Vue, Angular and vanilla TypeScript so you can run the same problem in whichever one you might be handed, and a typical problem takes 15 to 25 minutes, which is roughly one practice loop. If you want the full catalogue and the solution walkthroughs, UIReady Premium is where that lives.