Destructuring and the Spread Operator

8 min read

Two pieces of modern JavaScript syntax change how you handle objects and arrays in test code. Destructuring unpacks values out of structures into named variables in one line. Spread does the opposite — it expands a structure into another, perfect for merging configs or copying arrays. Both are everywhere in modern test frameworks; you'll read them on day one and write them on day three.

Object destructuring

The basic shape: name the properties you want on the left, give the object on the right.

const user = { name: "Alice", role: "admin", age: 32 };
 
const { name, role } = user;
console.log(name);  // "Alice"
console.log(role);  // "admin"

The names inside {} must match the property names in the object. The result is two new variables, name and role, holding the values from the user object. You can pick as many or as few properties as you want — anything you don't name is just ignored.

Renaming during destructuring

If the property name doesn't suit you (clashes with an existing variable, or is too generic), rename it on the way out with ::

const user = { name: "Alice", role: "admin" };
 
const { name: userName, role: userRole } = user;
console.log(userName);  // "Alice"
console.log(userRole);  // "admin"

The syntax reads as "take property name, call it userName." Useful when destructuring multiple objects with overlapping field names — const { id: userId } = user; const { id: orderId } = order; keeps both.

Default values

If a property is missing or undefined, you can supply a fallback with =:

const config = { baseUrl: "https://staging.com" };
 
const { baseUrl, timeout = 5000, retries = 3 } = config;
console.log(baseUrl);   // "https://staging.com"
console.log(timeout);   // 5000  — used the default
console.log(retries);   // 3     — used the default

This pairs well with default function parameters — many test helpers destructure their options object with defaults baked in: function login({ user, password = "Test@1234" }).

Array destructuring

Same idea, but with positions instead of names. Use [] and the variable name position matches the array index.

const browsers = ["Chrome", "Firefox", "Safari"];
 
const [first, second] = browsers;
console.log(first);   // "Chrome"
console.log(second);  // "Firefox"

You can skip items by leaving an empty slot — useful when you only want some indices:

const browsers = ["Chrome", "Firefox", "Safari", "Edge"];
 
const [, , third] = browsers;
console.log(third);   // "Safari"

The two leading commas skip indices 0 and 1.

The rest pattern

Both forms support the rest pattern...name collects "everything else" into an array (for arrays) or object (for objects).

const browsers = ["Chrome", "Firefox", "Safari", "Edge"];
const [head, ...tail] = browsers;
console.log(head);   // "Chrome"
console.log(tail);   // ["Firefox", "Safari", "Edge"]
 
const user = { name: "Alice", role: "admin", email: "alice@test.com" };
const { name, ...rest } = user;
console.log(name);   // "Alice"
console.log(rest);   // { role: "admin", email: "alice@test.com" }

Common in test code: "extract the fields I care about, keep everything else for logging."

The spread operator (...)

The same ... spelling, used in a different position, does the opposite — it expands an array or object back out, into another array or object.

Spreading arrays:

const desktop = ["Chrome", "Firefox"];
const mobile = ["Safari iOS", "Chrome Android"];
 
const allBrowsers = [...desktop, ...mobile];
console.log(allBrowsers);
// ["Chrome", "Firefox", "Safari iOS", "Chrome Android"]

[...desktop, ...mobile] builds a new array from the contents of both. The originals are untouched — spread is a non-mutating way to combine, copy, or insert into arrays.

Spreading objects:

const defaults = { timeout: 5000, retries: 3 };
const overrides = { timeout: 10000 };
 
const final = { ...defaults, ...overrides };
console.log(final);  // { timeout: 10000, retries: 3 }

Two important rules:

  1. The result is a new object — neither input is mutated.
  2. Later wins — when keys collide, the spread that comes later overrides the earlier one. That's what makes "defaults plus overrides" work: write defaults first, overrides second.

Why this matters for QA

Two patterns dominate.

Pattern 1 — extracting fields from an API response.

const response = {
  status: 200,
  data: {
    users: [
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" }
    ]
  }
};
 
const { status, data: { users } } = response;
console.log(status);  // 200
console.log(users);   // [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]

The pattern data: { users } is nested destructuring — reach into data, pull out users. One line replaces three.

Pattern 2 — building a config from defaults and overrides.

const defaultConfig = {
  baseUrl: "https://staging.com",
  timeout: 5000,
  retries: 3,
  headless: true
};
 
const stagingOverrides = {
  baseUrl: "https://staging-eu.com",
  timeout: 10000
};
 
const finalConfig = { ...defaultConfig, ...stagingOverrides };
console.log(finalConfig);

Output:

{
  baseUrl: 'https://staging-eu.com',
  timeout: 10000,
  retries: 3,
  headless: true
}

baseUrl and timeout were overridden; retries and headless were inherited. Every test framework worth using has this pattern in its core — a base config plus per-environment or per-run overrides.

How a config merge actually works

Combining destructure and spread

The two are most useful together. A common helper: take an options object, destructure the fields you need with defaults, spread the rest into a child call.

function loginAs({ user, password = "Test@1234", ...extra }) {
  console.log(`Logging in as ${user} with password ${password}`);
  console.log("Extra options forwarded:", extra);
}
 
loginAs({ user: "alice", remember: true, twoFactor: false });

Output:

Logging in as alice with password Test@1234
Extra options forwarded: { remember: true, twoFactor: false }

user and password get pulled out by name. Anything else gets collected into extra, which the function can forward to a lower-level helper.

⚠️ Common mistakes

  • Renaming syntax mix-up. const { name = userName } sets a default, it does NOT rename. Renaming is const { name: userName } (colon, no equals). Both at once: const { name: userName = "anonymous" }.
  • Spread does a shallow copy. const copy = { ...config } copies the top-level fields. Nested objects are still shared by reference — mutating copy.api.baseUrl mutates config.api.baseUrl too. For deep copies, use structuredClone(obj) or a JSON round-trip (next lesson).
  • Spread order in object merges. { ...overrides, ...defaults } makes defaults win, which is the opposite of what you usually want. The mantra is "defaults first, overrides last."

🎯 Practice task

Build a config merge helper. 15-20 minutes.

  1. In your js-for-qa folder, create merge.js.

  2. Declare defaultConfig with baseUrl, timeout, retries, and headless.

  3. Declare stagingOverrides with two of those keys (different values), and prodOverrides with one.

  4. Build stagingFinal and prodFinal using object spread. Print both.

  5. From stagingFinal, destructure { baseUrl, timeout } into local variables. Print them.

  6. Declare const response = { status: 200, data: { user: { id: 1, name: "Alice" } } };. Use one nested destructure to pull status and the inner name out:

    const { status, data: { user: { name } } } = response;

    Print both.

  7. Stretch: write a mergeConfig(...sources) function that uses rest parameters and returns Object.assign({}, ...sources) (or repeated spread) to merge any number of config objects, with later ones winning. Test it with 3-4 config objects.

The next (and last) lesson of this chapter covers JSON — the format every API and fixture file in your test suite is going to be in.

// tip to track lessons you complete and pick up where you left off across devices.