bastianplsfix

Destructuring

Destructuring lets you write a pattern in a place where a variable name would go. The pattern has the shape of the data, and each name inside it receives the matching piece.

There are two kinds, and the difference between them explains almost everything else: an array pattern, [a, b], takes values by position, by iterating, and an object pattern, {a, b}, takes values by property name. Both work anywhere a binding is created: a const or let declaration, an assignment to existing names, a function parameter, and the head of a for-of loop. It is one feature with a lot of syntax, so the working model below is longer than usual, and everything in it is the same idea seen in a different position.

Create programs/destructuring.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

an array pattern takes by position

Deno.test("an array pattern takes by position", () => {
const [first, second] = ["a", "b", "c"];
assertEquals([first, second], ["a", "b"]);

const [, middle] = ["a", "b", "c"];
assertStrictEquals(middle, "b");

const [head, ...tail] = [1, 2, 3];
assertStrictEquals(head, 1);
assertEquals(tail, [2, 3]);
});
Check programs/destructuring.test.ts
running 1 test from ./programs/destructuring.test.ts
an array pattern takes by position ... ok (335µs)

ok | 1 passed | 0 failed (2ms)

Taking fewer names than there are values is fine and normal. A gap skips a position, and a ...rest at the end collects everything remaining into a new array. The rest element must be last, and a later step gets the parser to say so.

an object pattern takes by name

Deno.test("an object pattern takes by name", () => {
type Person = { name: string; age: number; city: string; nickname?: string };
const person: Person = { name: "Ada", age: 36, city: "London" };

const { name, age } = person;
assertEquals([name, age], ["Ada", 36]);

const { age: years } = person;
assertStrictEquals(years, 36);

const { nickname = "none" } = person;
assertStrictEquals(nickname, "none");

const { name: _who, ...others } = person;
assertEquals(others, { age: 36, city: "London" });
});
an object pattern takes by name ... ok (61µs)

Four things in one pattern language, and the order inside each entry is always the same: property name, then : and the local name you want, then = and a default. So { age: years = 0 } reads as "take age, call it years, and use 0 if it is missing". The colon is the confusing part, because it is doing the opposite of what it does in an object literal: there the name on the left is being created, here the name on the right is. ...others collects the remaining own enumerable properties into a new object.

patterns nest

Deno.test("patterns nest", () => {
const response = { rows: [{ id: 7, tags: ["new"] }] };

const { rows: [{ id, tags: [firstTag] }] } = response;

assertStrictEquals(id, 7);
assertStrictEquals(firstTag, "new");
});
patterns nest ... ok (33µs)

An array pattern inside an object pattern inside an array pattern. The pattern is a picture of the data with names where the values are, which is the whole idea and also, past two levels, a good reason to stop, which the depths come back to.

four places a pattern can go

Deno.test("four places a pattern can go", () => {
const source = { name: "Ada", scores: [1, 2] };

const { name } = source;
assertStrictEquals(name, "Ada");

let scores: number[] = [];
({ scores } = source);
assertEquals(scores, [1, 2]);

const lengthOf = ({ scores }: { scores: number[] }) => scores.length;
assertStrictEquals(lengthOf(source), 2);

const seen: string[] = [];
for (const [index, value] of ["a", "b"].entries()) {
seen.push(`${index}:${value}`);
}
assertEquals(seen, ["0:a", "1:b"]);
});
four places a pattern can go ... ok (70µs)

A declaration, an assignment, a parameter, and a for-of head. The for-of case is the one you will write most, and it is why the loops page can recommend staying inside for-of when you need the index: .entries() yields [index, element] pairs and the pattern takes them apart. The parameter case has its own rules, which the parameters and arguments page covers in named parameters are an object, plus = {}. Note the parentheses around the assignment: they are required, and a later step explains why.

one pattern iterates, the other reads properties

Deno.test("one pattern iterates, the other reads properties", () => {
const [firstSeen] = new Set(["x", "y"]);
assertStrictEquals(firstSeen, "x");

const [firstChar, secondChar] = "a🙂";
assertEquals([firstChar, secondChar], ["a", "🙂"]);

const { length } = "oak";
assertStrictEquals(length, 3);

const { 0: first, 2: third } = ["a", "b", "c"];
assertEquals([first, third], ["a", "c"]);

const { length: count } = ["a", "b"];
assertStrictEquals(count, 2);

function* naturals(): Generator<number> {
let n = 1;
while (true) yield n++;
}

const [one, two] = naturals();
assertEquals([one, two], [1, 2]);
});
one pattern iterates, the other reads properties ... ok (63µs)

An array pattern does not use indices. It asks the value for an iterator and pulls as many times as it has names, which is why it works on a Set, and on anything else that implements the protocol from iterables and iterators. On a string it yields code points, so the emoji arrives in one piece, from text and characters. An object pattern reads properties, so it works on anything that has them, including a primitive with a wrapper, which is where length comes from.

Which means an object pattern works on an array, since an array's indices are property keys, the first fact on the arrays page: {0: first} reads property "0" the same way {name} reads property "name". Not something to write often, and worth seeing once, because it shows the two pattern kinds are not two features: the array pattern is the one doing something special, by going through iteration. It is occasionally the right tool, since picking positions 0 and 7 out of a long array takes one line this way and six commas the other way. And because it iterates, a pattern takes only what it needs: naturals yields forever, two names means two pulls, and nothing asks for a third.

a default fires on undefined and nothing else

Predict what the default does with null:

Deno.test("a default fires on undefined and nothing else", () => {
const { a = "fallback" } = { a: undefined };
const { b = "fallback" } = { b: null };
const { c = "fallback" } = { c: 0 };
const { d = "fallback" } = { d: "" };

assertStrictEquals(a, "fallback");
assertStrictEquals(b, "fallback");
assertStrictEquals(c, 0);
assertStrictEquals(d, "");

const [p = "fallback", q = "fallback"] = [undefined, null];
assertEquals([p, q], ["fallback", null]);
});
Check programs/destructuring.test.ts
running 6 tests from ./programs/destructuring.test.ts
...
a default fires on undefined and nothing else ... FAILED (7ms)

ERRORS

a default fires on undefined and nothing else => ./programs/destructuring.test.ts:86:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- null
+ "fallback"

FAILURES

a default fires on undefined and nothing else => ./programs/destructuring.test.ts:86:6

FAILED | 5 passed | 1 failed (9ms)

error: Test failed

The null came through untouched. A missing property and a property set to undefined are treated the same, and everything else is left alone: null is not missing, it is present and deliberate, the distinction the nothing, twice page is built around. Correct the prediction to null:

a default fires on undefined and nothing else ... ok (43µs)

This is the same rule as a parameter default, where the parameters and arguments page ran the same experiment, and the same rule as ??, and the three being consistent is worth noticing: reach for || instead and you get a different rule, where 0 and "" are replaced too.

a default is an expression, and it is lazy

First, the TypeScript rule that will bite. Write a default against a type that does not admit the property:

Deno.test("a default is an expression, and it is lazy", () => {
type Person = { name: string };
const person: Person = { name: "Ada" };

const { nickname = "none" } = person;
assertStrictEquals(nickname, "none");
});
Check programs/destructuring.test.ts
TS2339 [ERROR]: Property 'nickname' does not exist on type 'Person'.
const { nickname = "none" } = person;
~~~~~~~~
at file:///programs/destructuring.test.ts:105:11

error: Type checking failed.

A default does not make the property optional in the type. The type has to admit the property could be missing, with nickname?: string, before the default is allowed to handle it, which is how the earlier Person was spelled. Replace the test with the laziness measurements:

Deno.test("a default is an expression, and it is lazy", () => {
let expensiveCalls = 0;
function expensive(): string {
expensiveCalls++;
return "computed";
}

const { given = expensive() } = { given: "provided" };
assertStrictEquals(given, "provided");
assertStrictEquals(expensiveCalls, 0);

const { absent = expensive() } = {} as { absent?: string };
assertStrictEquals(absent, "computed");
assertStrictEquals(expensiveCalls, 1);

const { width, height = width * 2 } = { width: 3 };
assertEquals([width, height], [3, 6]);
});
a default is an expression, and it is lazy ... ok (37µs)

The default is evaluated only when it is needed, so an expensive call or one with side effects is safe there: expensiveCalls stayed at zero until a default actually fired. It can also refer to names bound earlier in the same pattern, left to right, as height does, which is occasionally exactly what you want and, used twice in one pattern, something nobody will enjoy reading.

destructuring nothing throws, and names the kind

Coming from optional chaining, predict what a nested pattern does with a missing level:

Deno.test("destructuring nothing throws, and names the kind", () => {
const shallow = {} as { deep?: { inner: string } };

const { deep: { inner } } = shallow as { deep: { inner: string } };

assertStrictEquals(inner, undefined);
});
Check programs/destructuring.test.ts
running 8 tests from ./programs/destructuring.test.ts
...
destructuring nothing throws, and names the kind ... FAILED (265µs)

ERRORS

destructuring nothing throws, and names the kind => ./programs/destructuring.test.ts:120:6
error: TypeError: Cannot read properties of undefined (reading 'inner')
const { deep: { inner } } = shallow as { deep: { inner: string } };
^

FAILURES

destructuring nothing throws, and names the kind => ./programs/destructuring.test.ts:120:6

FAILED | 7 passed | 1 failed (2ms)

error: Test failed

It throws. A nested pattern is a chain of property reads with nothing between them: there is no ?. hiding inside it and no per-level check, so one absent level throws the ordinary error, and the cast is what it took to get past the checker, which catches this when the type says the level is optional. Pin all four failure shapes:

Deno.test("destructuring nothing throws, and names the kind", () => {
assertThrows(
() => {
const { nope } = null as unknown as { nope: string };
return nope;
},
TypeError,
"Cannot destructure property 'nope' of 'null' as it is null.",
);

assertThrows(
() => {
const [only] = undefined as unknown as string[];
return only;
},
TypeError,
"undefined is not iterable",
);

assertThrows(
() => {
const [n] = 5 as unknown as number[];
return n;
},
TypeError,
"5 is not iterable",
);

const shallow = {} as { deep?: { inner: string } };
assertThrows(
() => {
const { deep: { inner } } = shallow as { deep: { inner: string } };
return inner;
},
TypeError,
"Cannot read properties of undefined (reading 'inner')",
);
});
destructuring nothing throws, and names the kind ... ok (303µs)

Both pattern kinds fail on null and undefined, and they fail differently, which is useful when reading a stack trace. An object pattern names the property it was reaching for. An array pattern complains about iteration, because that is what it was doing, and the same message appears for any non-iterable value.

a tuple's length is known, an array's is not

Two lines that look alike:

Deno.test("a tuple's length is known, an array's is not", () => {
const [a, b] = [1];

assertStrictEquals(a, 1);
assertStrictEquals(b, undefined);
});
Check programs/destructuring.test.ts
TS2493 [ERROR]: Tuple type '[number]' of length '1' has no element at index '1'.
const [a, b] = [1];
^
at file:///programs/destructuring.test.ts:160:13

error: Type checking failed.

Destructuring straight from a literal gives TypeScript a tuple type, so it knows there is one element and reports the second name as an error. Route the same value through a variable and the complaint disappears:

Deno.test("a tuple's length is known, an array's is not", () => {
const list = [1];
const [c, d] = list;

assertStrictEquals(c, 1);
assertStrictEquals(d, undefined);
});
a tuple's length is known, an array's is not ... ok (23µs)

list is number[], a length is not part of that type, and d gets the type number while holding undefined. That is the same optimism about indexed reads that the arrays page measured in the checker is optimistic about brackets, appearing in pattern form. The rule of thumb: a pattern is as safe as the type says the source is, an array type never says how long it is, and a tuple type does, which is a reason to reach for one when a fixed layout is what you have.

object rest is how you omit a property

Deno.test("object rest is how you omit a property", () => {
const request = { id: 1, token: "secret", body: "hello" };
const { token: _token, ...safe } = request;

assertEquals(safe, { id: 1, body: "hello" });
assertEquals(request, { id: 1, token: "secret", body: "hello" });

const nested = { keep: { n: 1 }, drop: 2 };
const { drop: _drop, ...kept } = nested;
assertStrictEquals(kept.keep, nested.keep);
});
object rest is how you omit a property ... ok (35µs)

There is no "omit" operation in JavaScript, and this is the idiom that replaces one: name the property you want gone, collect the rest, ignore the name. The underscore is a convention that also keeps the linter quiet about the unused binding. TypeScript computes the resulting type, so safe is { id: number; body: string } with the omitted property genuinely gone rather than optional, which makes this a real tool for types and not only for values. The copy is shallow, like every other copy of an object, from values and references.

the parser rules, in three refusals

Deno.test("the parser rules, in three refusals", () => {
assertThrows(
() => new Function("let name; { name } = {name: 1};"),
SyntaxError,
"Unexpected token '='",
);

assertStrictEquals(
new Function("let name; ({ name } = {name: 1}); return name;")(),
1,
);

assertThrows(
() => new Function("const [a];"),
SyntaxError,
"Missing initializer in destructuring declaration",
);
assertThrows(
() => new Function("const [...a, b] = [1, 2];"),
SyntaxError,
"Rest element must be last element",
);
});
the parser rules, in three refusals ... ok (86µs)

The first pair is the syntax rule that makes destructuring look temperamental, and the reason is not about destructuring at all. A statement that starts with { is a block, so the parser reads { name } as a block containing an expression, then finds an = where a statement should start; wrapping the whole assignment in parentheses makes it an expression, and it parses. Only object patterns are affected, because a statement can start with [, which is why the swap further down needs nothing. You will not hit this in a declaration, since const {name} = source starts with const; it only appears when assigning to existing names, which is rare enough that the error is easy to misdiagnose. The other two refusals: a destructuring declaration must have an initializer, and a rest element must be last. All three are SyntaxErrors observed the only way possible, by compiling a string, the trick from the functions page's a function built at run time.

a catch pattern does not type-check here

Legal JavaScript, tidy-looking, and rejected:

Deno.test("a catch pattern does not type-check here", () => {
try {
throw new Error("boom");
} catch ({ message }) {
assertStrictEquals(message, "boom");
}
});
Check programs/destructuring.test.ts
TS2339 [ERROR]: Property 'message' does not exist on type 'unknown'.
} catch ({ message }) {
~~~~~~~
at file:///programs/destructuring.test.ts:206:14

error: Type checking failed.

A catch binding is unknown under Deno's defaults, because anything can be thrown, and you cannot destructure a property out of unknown. The fix is the one the errors and exceptions page recommends anyway: catch the value, narrow it, and then read from it. This is a case where a pattern hides an assumption, and the checker is right to want the assumption written down:

Deno.test("a catch pattern does not type-check here", () => {
try {
throw new Error("boom");
} catch (error) {
if (error instanceof Error) {
const { message } = error;
assertStrictEquals(message, "boom");
}
}
});
a catch pattern does not type-check here ... ok (28µs)

a parameter list is an array pattern

Deno.test("a parameter list is an array pattern", () => {
function direct(first: string, second = "default", ...rest: string[]) {
return `${first}/${second}/${rest.join(",")}`;
}

function viaPattern(...args: string[]) {
const [first, second = "default", ...rest] = args;
return `${first}/${second}/${rest.join(",")}`;
}

assertStrictEquals(direct("x"), viaPattern("x"));
assertStrictEquals(direct("x", "y", "z"), viaPattern("x", "y", "z"));

assertStrictEquals(direct.length, 1);
assertStrictEquals(viaPattern.length, 0);
});
a parameter list is an array pattern ... ok (34µs)

The two functions behave identically, and that is not a coincidence. A parameter list is an array pattern applied to the arguments, which is why parameters have defaults, why the last one can be a rest element, and why a parameter can itself be a pattern: one idea explaining three features that otherwise look unrelated. It also explains the restrictions, because a rest parameter must be last and there is no such thing as a named argument, since names were never how the matching worked. The equivalence is behavioural rather than total: a function's length counts parameters before the first default or rest, from the parameters and arguments page's length is the arity a caller must supply, so the two versions report different arities even though they accept the same calls.

One neighbouring look-alike to file separately: named imports. import { readFile as read } from "./io.ts" has braces that look like an object pattern and are not one: as does the renaming rather than :, the braces cannot nest, and the names stay live bindings into the exporting module rather than copies out of an object, from the modules page. Two syntaxes that resemble each other and share no rules, and the resemblance is what causes the mistake.

two answers, one object

Deno.test("two answers, one object", () => {
type Found<T> = { value: T | undefined; index: number };

function findEntry<T>(values: T[], match: (value: T) => boolean): Found<T> {
for (const [index, value] of values.entries()) {
if (match(value)) return { value, index };
}
return { value: undefined, index: -1 };
}

const numbers = [7, 8, 9];

const { value, index } = findEntry(numbers, (n) => n % 2 === 0);
assertEquals([value, index], [8, 1]);

const { index: justTheIndex } = findEntry(numbers, (n) => n % 2 === 0);
assertStrictEquals(justTheIndex, 1);

let left = "a";
let right = "b";
[left, right] = [right, left];
assertEquals([left, right], ["b", "a"]);

const scores = new Map([["ada", 1], ["grace", 2]]);
const lines: string[] = [];
for (const [who, score] of scores) {
lines.push(`${who}=${score}`);
}
assertEquals(lines, ["ada=1", "grace=2"]);
});
two answers, one object ... ok (74µs)

Return an object when a function has two answers, and let the caller take whichever it wants. This dissolves a problem that otherwise leads to two near-identical functions, or to one that takes a flag saying which answer you want: find and findIndex on arrays are that duplication, preserved in the standard library. A tuple would work and an object is better, for two reasons the pattern makes obvious: the names travel with the values, so {index} at the call site says what it is where [, index] needs counting, and taking only one is free. The swap and the Map loop are the other two idioms worth having ready: no temporary name for the swap, no parentheses because the statement starts with [, and [key, value] pairs taken apart in the for-of head.

In practice