Destructuring, Spread & Rest Interview Questions & Answers

32 questions Updated 2026-06-18

JavaScript destructuring, spread and rest interview questions — object destructuring with renaming and defaults, nested and computed keys, object spread for copying and merging, rest parameters, and the shallow-copy gotcha.

Read the in-depth guideJavaScript Destructuring, Spread & Rest — The Complete Guide to Objects and Arrays

Destructuring is syntax that unpacks values from objects (or arrays) into distinct variables in a single expression. It replaces repetitive property-by-property assignment with a compact pattern that mirrors the shape of the data.

The mechanism: the pattern on the left of = is matched against the value on the right, binding each name to the corresponding property.

const user = { name: 'Ada', age: 36 }
const { name, age } = user           // two variables in one line
console.log(name, age)               // 'Ada' 36
// verbose old way:
// const name = user.name; const age = user.age

Pitfall: destructuring reads properties — it does not mutate the source object. user is untouched after the line above.

It matches by property name, not by position (unlike array destructuring). The variable name must equal the key you want to extract.

const point = { x: 1, y: 2 }
const { y, x } = point               // order doesn't matter
console.log(x, y)                    // 1 2
const { z } = point                  // z is undefined (no such key)

Pitfall: because matching is by name, a typo silently produces undefined rather than an error — there is no "this key doesn't exist" warning.

Use the key: newName syntax. The left side of the colon is the property to read; the right side is the local variable to bind it to.

const res = { status: 200, data: 'ok' }
const { status: code, data: body } = res   // rename both
console.log(code, body)                     // 200 'ok'
// console.log(status)                      // ReferenceError — status not defined

The original key name (status) is not in scope afterward — only the new name is. Pitfall: people read status: code as "assign status to code" but it's the reverse — read status, name it code.

A default after = is applied only when the extracted value is undefined (a missing key or an explicit undefined). Any other value — including null, 0, '', or false — is kept as-is.

const { timeout = 1000, retries = 3 } = { retries: 0 }
console.log(timeout, retries)        // 1000 0  0 is kept, default not applied
const { x = 5 } = { x: null }
console.log(x)                       // null  default NOT used — null isn't undefined

Pitfall: defaults trigger only on undefined, so null slips through. If you need a fallback for null too, combine with ?? after extracting.

Stack the two syntaxes: key: newName = default. JavaScript reads key, binds it to newName, and falls back to default if that value is undefined.

const settings = {}
const { mode: displayMode = 'light' } = settings
console.log(displayMode)             // 'light'  renamed AND defaulted

The order is fixed: property name, then colon and new name, then equals and default. Pitfall: writing mode = 'light': displayMode is a syntax error — the default attaches to the renamed binding, after the colon.

Nest a pattern wherever a value would be a sub-object. The colon here means "descend into this object" rather than "rename".

const user = { name: 'Ada', address: { city: 'London', zip: 'SW1' } }
const { address: { city } } = user
console.log(city)                    // 'London'
// console.log(address)              // ReferenceError — address itself not bound

Two gotchas: (1) the intermediate name (address) is not created as a variable — only the leaf (city) is. (2) If address were undefined, destructuring into it throws TypeError: Cannot destructure property 'city' of undefined — add a default: { address: { city } = {} }.

Use computed key syntax [expr]: target. The bracketed expression is evaluated to a string key, and you must supply a binding name after the colon (there's no implicit name to infer).

const field = 'email'
const record = { email: 'a@b.com', name: 'Ada' }
const { [field]: value } = record
console.log(value)                   // 'a@b.com'  dynamic key extracted

Pitfall: you must name the target (: value) — const { [field] } = record is a syntax error because the engine can't derive a variable name from an arbitrary expression.

Put the pattern directly in the parameter list. The caller passes one object and the function pulls out named fields — a common alternative to long positional argument lists.

function createUser({ name, role = 'user', active = true }) {
  return `${name}:${role}:${active}`
}
createUser({ name: 'Ada', role: 'admin' })   // 'Ada:admin:true'

This gives named, order-independent arguments with per-field defaults. Pitfall: calling createUser() with no argument throws, because you can't destructure undefined. Guard with a default: function createUser({ ... } = {}).

Destructuring undefined throws a TypeError. Defaulting the whole parameter to {} lets the function be called with no argument while still applying each field's individual defaults.

function connect({ host = 'localhost', port = 5432 } = {}) {
  return `${host}:${port}`
}
connect()                            // 'localhost:5432' — no throw
// without the = {}, connect() would throw TypeError

Two levels of defaulting are at work: = {} guards the missing-object case, and host = ... / port = ... guard missing fields. Pitfall: forgetting the outer = {} makes the function fragile to no-arg calls.

...rest collects all remaining own enumerable properties not already named into a new object. It's how you extract a few fields and keep the rest grouped.

const props = { id: 1, name: 'Ada', role: 'admin' }
const { id, ...rest } = props
console.log(id)                      // 1
console.log(rest)                    // { name: 'Ada', role: 'admin' }  new object

The rest object is a new shallow object, distinct from the source. Pitfall: the rest element must be lastconst { ...rest, id } is a syntax error.

Name the property to drop, then capture ...rest. The named binding is discarded and rest holds everything else — an immutable "omit" pattern.

const user = { id: 1, password: 'secret', name: 'Ada' }
const { password, ...safe } = user
console.log(safe)                    // { id: 1, name: 'Ada' }  password removed
// user is unchanged — original still has password

This is purely a copy; the original is untouched. Pitfall: password is now an unused variable in scope — some linters flag it; an underscore prefix or // eslint-disable comment is the usual fix.

...obj inside an object literal copies that object's own enumerable properties into the new object. It's the concise way to clone or extend.

const base = { a: 1, b: 2 }
const copy = { ...base }             // shallow clone
const extended = { ...base, c: 3 }   // add a property
console.log(extended)                // { a: 1, b: 2, c: 3 }

Spread only copies own enumerable properties — inherited (prototype) and non-enumerable properties are skipped. Pitfall: it's a shallow copy, so nested objects are shared by reference, not duplicated.

List both with ... in one literal. Properties are copied left-to-right, so a later object's values override earlier ones for duplicate keys.

const defaults = { theme: 'light', size: 'md' }
const overrides = { size: 'lg' }
const config = { ...defaults, ...overrides }
console.log(config)                  // { theme: 'light', size: 'lg' }

The last write wins for any shared key. Pitfall: order matters — putting ...defaults last would let defaults clobber your overrides, which is almost always a bug.

The object literal is built left to right, and each later key with the same name overwrites the earlier one. This works whether the key comes from a spread or is written literally.

const obj = { a: 1 }
console.log({ ...obj, a: 2 })        // { a: 2 }  literal after spread wins
console.log({ a: 2, ...obj })        // { a: 1 }  spread after literal wins

Position, not source type, decides the winner. Pitfall: spreading after your explicit overrides silently undoes them — keep explicit keys after the spreads you want them to win against.

Spread copies the top-level property values. When a value is an object, it copies the reference, not a fresh clone — so nested objects are shared between the original and the copy.

const original = { name: 'Ada', meta: { active: true } }
const copy = { ...original }
copy.meta.active = false
console.log(original.meta.active)    // false  original mutated too!
copy.name = 'Bob'
console.log(original.name)           // 'Ada'  top-level is independent

Top-level properties are independent; nested ones are not. For a deep copy use structuredClone(original) (or libraries). Pitfall: mutating nested state on a "copy" silently corrupts the source — a frequent source of state bugs.

Both do a shallow merge of own enumerable properties, but Object.assign(target, ...sources) mutates target and returns it, whereas { ...a, ...b } always creates a new object.

const target = { a: 1 }
Object.assign(target, { b: 2 })      // mutates target -> { a: 1, b: 2 }
const fresh = { ...target, c: 3 }    // new object, target untouched

Subtler difference: Object.assign invokes setters on the target, while spread defines plain data properties on a fresh object. Pitfall: passing a real object as Object.assign's first arg unintentionally mutates it — pass {} first (Object.assign({}, a, b)) for a non-mutating merge.

Spread invokes each getter and copies its returned value as a plain data property — the getter itself is not carried over. The copy holds a static snapshot, not a live accessor.

const obj = { _n: 1, get n() { return this._n } }
const copy = { ...obj }
obj._n = 99
console.log(copy.n)                  // 1  frozen snapshot, getter gone
console.log(Object.getOwnPropertyDescriptor(copy, 'n').get)  // undefined

To preserve accessors you need Object.getOwnPropertyDescriptors + Object.defineProperties. Pitfall: spreading objects with getters silently flattens computed/lazy properties into stale values.

A rest parameter (...args) collects all remaining arguments into a real array. It's the modern, array-native replacement for the arguments object.

function sum(...nums) {
  return nums.reduce((t, n) => t + n, 0)
}
sum(1, 2, 3)                         // 6 — nums is [1, 2, 3]

Unlike arguments, nums is a genuine Array with map/reduce/etc., and it works in arrow functions. Pitfall: the rest parameter must be lastfunction f(...a, b) is a syntax error.

arguments is an array-like object — no array methods, and it doesn't exist in arrow functions. Rest parameters give a real array and work everywhere.

function oldWay() {
  const args = Array.prototype.slice.call(arguments)  // awkward conversion
  return args.map(x => x * 2)
}
const newWay = (...args) => args.map(x => x * 2)       // clean, works in arrows

Rest also makes the signature self-documenting and can capture just the tail after named params. Pitfall: arguments reflects all passed args ignoring parameter names, which surprises people refactoring to rest.

List the fixed parameters first, then ...rest last to gather everything beyond them. The named params consume the leading arguments; the rest collects the tail.

function log(level, ...messages) {
  console.log(`[${level}]`, messages.join(' '))
}
log('INFO', 'server', 'started')     // level='INFO', messages=['server','started']

rest is empty ([]) if no extra arguments are passed — never undefined. Pitfall: rest captures everything after the named params, so you can't place a required parameter after it.

Use spread at the call site: fn(...arr) expands the array elements into positional arguments. This replaces the older fn.apply(null, arr).

const nums = [3, 1, 4, 1, 5]
Math.max(...nums)                    // 5 — same as Math.max(3,1,4,1,5)
// Math.max(nums)                    // NaN — array isn't a number

You can mix spread with literals: fn(0, ...nums, 9). Pitfall: spreading a very large array can exceed the engine's argument-count limit and throw RangeError.

Array destructuring lets you assign both sides simultaneously: the right side is evaluated into a temporary array, then unpacked back into the variables.

let a = 1, b = 2
;[a, b] = [b, a]                     // a=2, b=1, no temp needed

The leading semicolon guards against the previous line being interpreted as a function call on [. Pitfall: forgetting that semicolon when the prior line has no ; causes 2[a, b] ASI bugs — a classic gotcha.

A function can return one object and the caller destructures the fields it cares about — order-independent, self-documenting, and easy to extend without breaking callers.

function parse(input) {
  return { ok: true, value: input.trim(), length: input.length }
}
const { value, ok } = parse('  hi ') // pick fields by name, any order

Adding a new field to the returned object never breaks existing callers (unlike adding a positional array element). Pitfall: if the function can return null (e.g. a "not found" case), destructuring it throws — guard with ?? {}.

Each level of a nested pattern can carry its own default. A default on an intermediate object protects against that object being undefined before you descend into it.

function render({ theme: { color = 'black' } = {} } = {}) {
  return color
}
render()                             // 'black' — every level defaulted
render({ theme: null })              // TypeError — null isn't undefined, can't destructure

Defaults fire on undefined only, so a null intermediate still throws. Pitfall: callers passing explicit null for a sub-object bypass your = {} guard — defend with ?? {} if null is possible.

They are mirror images. Rest collects multiple items into one array/object (appears in a parameter list or destructuring pattern). Spread expands one iterable/object into multiple items (appears in a call, array, or object literal).

const [first, ...rest] = [1, 2, 3]   // rest: collects -> rest = [2, 3]
const merged = [first, ...rest]      // spread: expands -> [1, 2, 3]

Rule of thumb: on the left of = or in params it's rest (gathering); on the right or inside a literal/call it's spread (scattering). Pitfall: same ... token, opposite directions — context decides which.

In an array literal or function call, spread works on any iterable — strings, Set, Map, arguments, generators. In an object literal, spread works on any object's own enumerable properties.

[...'abc']                           // ['a', 'b', 'c']  string is iterable
[...new Set([1, 1, 2])]              // [1, 2]  dedupe trick
{ ...{ a: 1 } }                      // { a: 1 }  object spread
// [...{ a: 1 }]                     // TypeError — plain object isn't iterable

Pitfall: object spread and array spread are different mechanisms — a plain object is not iterable, so [...obj] throws even though { ...obj } works.

Renaming during destructuring sidesteps collisions with existing variables or reserved-ish names, and lets you give local, meaningful names.

const a = 1
const data = { a: 99, default: 'x' }
const { a: itemId, default: fallback } = data
console.log(itemId, fallback)        // 99 'x'  no clash with outer `a`

Renaming also lets you read keys that aren't valid identifiers (like default) into usable names. Pitfall: forgetting to rename when a key shadows an in-scope const causes a redeclaration error.

Combine array and object patterns: the array pattern picks positions, and each element can itself be an object pattern that picks fields.

const users = [{ name: 'Ada' }, { name: 'Bob' }]
const [{ name: first }, { name: second }] = users
console.log(first, second)           // 'Ada' 'Bob'

You can default a missing element to {} to avoid throwing: [{ name } = {}] = users. Pitfall: if the array is shorter than the pattern, a missing element is undefined and destructuring into it throws unless you provide an element default.

Spread the old state and override the changed fields in a new object, leaving the original untouched — the foundation of immutable updates in Redux, React, etc.

const state = { count: 0, user: 'Ada' }
const next = { ...state, count: state.count + 1 }
console.log(state.count, next.count) // 0 1  original preserved

This makes change detection cheap (reference comparison) and supports time-travel/undo. Pitfall: it's shallow — updating a nested slice requires spreading each level ({ ...state, user: { ...state.user, ... } }), or nested mutations leak into the old state.

Yes. Defaults are evaluated left to right, so a later field's default can use a value bound earlier in the same pattern.

function area({ width, height = width } = {}) {
  return width * height
}
area({ width: 5 })                   // 25 — height defaults to width

The binding must already be in scope (declared earlier in the pattern). Pitfall: referencing a field declared after it throws a temporal-dead-zone ReferenceError{ a = b, b } fails because b isn't initialized yet when a's default runs.

In an object literal, spreading null or undefined is silently ignored — no properties added, no error. In an array literal or call, spreading them throws because they aren't iterable.

const o = { ...null, ...undefined, a: 1 }  // { a: 1 } — ignored safely
// const arr = [...null]                    // TypeError: null is not iterable

This asymmetry is handy: { ...(cond && extra) } conditionally merges (since false/null spread to nothing). Pitfall: the same cond && extra trick in an array spread throws when the condition is falsy.

Shallow. The rest pattern builds a new object whose nested values are still shared references with the source — only the top-level structure is new.

const src = { id: 1, meta: { x: 1 } }
const { id, ...rest } = src
rest.meta.x = 99
console.log(src.meta.x)              // 99  shared nested reference mutated

Same shallow semantics as object spread (they use the same copy mechanism). Pitfall: treating the rest object as fully isolated and mutating its nested members corrupts the original.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.