Skip to content

Destructuring

On this page

Destructuring binds several names at once by matching an array, struct, record, or enum. The same pattern language works at binding sites and, where noted, when assigning to existing variables.

Positional bindings

A tuple pattern extracts values by position in a let. It also works with a function that returns several values:

let (lo, hi) = min_max([...]) — destructures a function's return value into two names.

06-destructuring-array.zolo
Playground
// Feature: Array destructuring (tuple-pattern)

// Syntax: `let (a, b, c) = [v1, v2, v3]`

// When to use: extract multiple values in one line, simulate

// "tuples" on the value side (in Zolo, tuples are arrays).


// Destructure directly from a literal.

let (a, b, c) = [1, 2, 3]
print(a)  // 1

print(b)  // 2

print(c)  // 3


// Heterogeneous types — works because arrays are dynamic.

let (name, age, active) = ["Alice", 30, true]
print(name)  // Alice

print(age)  // 30

print(active)  // true


// Destructure from a function returning an array.

fn min_max(arr: [int]) -> [int] {
  var lo = arr[0]
  var hi = arr[0]
  for x in arr {
    if x < lo { lo = x }
    if x > hi { hi = x }
  }
  return [lo, hi]
}

let (lo, hi) = min_max([3, 1, 4, 1, 5, 9, 2, 6])
print("lo={lo} hi={hi}")  // lo=1 hi=9


// In loops — destructure each element.

let pairs = [[1, "one"], [2, "two"], [3, "three"]]
for pair in pairs {
  let (n, label) = pair
  print("{n}={label}")
}
// expected:

// 1=one

// 2=two

// 3=three

Struct bindings in let

Use let Point { x, y } = point when the value is known to be a Point. field: local renames a binding and .. ignores unnamed fields. Add an else block when the value may not match:

Direct struct patterns in let, field renaming, .., and the fallible let … else form.

08-destructuring-let.zolo
Playground
// Feature: Struct destructuring in `let` (guarded)

// Syntax: `let Type { field, other: renamed, .. } = expr`

// When to use: extract fields when you KNOW the value is that struct.

// If the value might not match, use `let ... else`, `if let`, or `match`.

// A non-matching value panics: "destructure failed: expected T, got ...".

// Note: pattern-`let` is statement-level (inside fn bodies), like let-else.


struct Point {
  x: int,
  y: int,
}

// Basic: bind by field name — no `else` needed when the type is known.

fn dist2(p: Point) -> int {
  let Point { x, y } = p
  return x * x + y * y
}

let p = Point { x: 3, y: 4 }
print("dist2={dist2(p)}")

// expected: dist2=25


// Rename with `field: new_name`, ignore the rest with `..`.

fn xof(p: Point) -> int {
  let Point { x: px, .. } = p
  return px
}

print("x={xof(p)}")

// expected: x=3


// Fallible source? Keep the `else` form — the else block must diverge.

fn describe(v) {
  let Point { x, .. } = v else {
    print("not a point")
    return
  }
  print("point at x={x}")
}

describe(p)
describe(42)

// expected: point at x=3

// expected: not a point

Struct patterns also work directly in function parameters, lambdas, and for loops. A named pattern implies its parameter type; an anonymous record pattern needs an explicit annotation:

Named and anonymous struct patterns in parameters, closures, and for.

09-destructuring-params.zolo
Playground
// Feature: Struct patterns in binding positions

// Syntax: `fn f(Point { x, y })`, `|{ name }| ...`, `for Point { x } in ...`

// When to use: extract fields right where the value arrives — no boilerplate

// `let` at the top of the body. A named pattern IMPLIES the parameter type

// (`fn dist(Point { x, y })` types the param as Point); anonymous patterns

// in fn params need an annotation (`{ name, .. }: User`).

// A non-matching value panics: "destructure failed: expected T, got ...".


struct Point {
  x: int,
  y: int,
}

struct User {
  name: str,
  age: int,
}

// Function parameter — named pattern, type implied.

fn dist2(Point { x, y }) -> int {
  return x * x + y * y
}

let p = Point { x: 3, y: 4 }
print("dist2={dist2(p)}")

// expected: dist2=25


// Anonymous pattern needs the annotation in fn position.

fn greet({ name, .. }: User) -> str {
  return "hi " + name
}

print(greet(User { name: "Alice", age: 30 }))

// expected: hi Alice


// Closures — the sweet spot: |{ field }| in HOF chains.

fn names_of(us) {
  return us.map(|{ name }| name)
}

let us = [User { name: "A", age: 1 }, User { name: "B", age: 2 }]
let ns = names_of(us)
print("{ns[0]}{ns[1]}")

// expected: AB


// for-in — destructure each element; a non-matching element panics.

fn sum_products(ps) -> int {
  let mut acc = 0
  for Point { x, y } in ps {
    acc = acc + x * y
  }
  return acc
}

print("sum={sum_products([Point { x: 1, y: 2 }, Point { x: 3, y: 4 }])}")

// expected: sum=14

Rest records and struct spread

..rest captures the fields that were not named into a typed anonymous record. In a named struct literal, ..source copies fields from another value; explicitly named fields take precedence:

Typed rest records, structural record parameters, and Type { field: value, ..source }.

10-records-rest-spread.zolo
Playground
// Feature: `..rest` bindings, record types, and struct-literal spread

// Syntax:

//   let Type { field, ..rest } = expr      — rest = record with the remaining fields

//   fn f(r: { name: str, age: int })       — anonymous record type annotation

//   Type { field: v, ..source }            — construction spread: named > spread > default

// When to use: split a struct into "the fields I want" + "everything else",

// pass field subsets without declaring a named struct, or copy-with-changes.

// Note: pattern-`let` is statement-level (inside fn bodies), like let-else.


struct User {
  name: str,
  age: int,
  email: str,
}

// `..rest` collects the fields the pattern did not extract into an

// anonymous record — typed, so rest.age works and rest.name (extracted)

// is a compile-time error (TE102).

fn split(u: User) {
  let User { name, ..rest } = u
  print("name={name}")
  print("rest.age={rest.age} rest.email={rest.email}")
}

let u = User { name: "Alice", age: 30, email: "alice@zolo.dev" }
split(u)

// expected: name=Alice

// expected: rest.age=30 rest.email=alice@zolo.dev


// Records are structural: any struct (or record) with AT LEAST those

// fields can be passed — no named type needed.

fn describe(r: { name: str, age: int }) -> str {
  return "{r.name} ({r.age})"
}
print(describe(u))

// expected: Alice (30)


// Construction spread: start from an existing value, override some fields.

// Precedence: named field > spread value > declared default.

let renamed = User { name: "Bob", ..u }
print("renamed={renamed.name}/{renamed.age}/{renamed.email}")

// expected: renamed=Bob/30/alice@zolo.dev


// Pure copy-construct.

let copy = User { ..u }
print("copy={copy.name}")

// expected: copy=Alice


struct Point {
  x: int,
  y: int,
  z: int = 0,
}

// Round trip: destructure with rest, rebuild with spread.

// `flat` holds { x, y }; the new literal overrides z and spreads the rest.

fn lift(p: Point) -> Point {
  let Point { z, ..flat } = p
  return Point { z: z + 10, ..flat }
}

let lifted = lift(Point { x: 1, y: 2 })
print("lifted={lifted.x},{lifted.y},{lifted.z}")

// expected: lifted=1,2,10


// The rest binding also works in anonymous patterns.

fn contact(u: User) {
  let { email, ..basics } = u
  print("email={email} basics.name={basics.name}")
}
contact(u)

// expected: email=alice@zolo.dev basics.name=Alice

Assignment to existing variables

Patterns without let assign to variables that already exist. The right-hand side is evaluated first, so (a, b) = (b, a) swaps safely. Struct assignment selects fields by name, and _ discards a positional slot:

Swap, Fibonacci state update, call-result unpacking, discard, and named struct assignment.

11-destructuring-assignment.zolo
Playground
// Feature: Destructuring assignment — re-assign EXISTING variables

// Syntax:

//   (a, b) = expr           — positional; RHS evaluated first (swap works)

//   (a, _) = expr           — `_` discards a slot

//   Point { x, y: py } = p  — by name; shorthand writes `x`, rename writes `py`

// When to use: update several existing variables at once — swaps, loop

// state steps, unpacking a returned pair into locals you already declared.

// This ASSIGNS (vars must exist, use `var`); to BIND new names, use

// `let (a, b) = ...` / `let Point { x } = ...` instead.

// A non-matching struct value panics: "destructure failed: expected T".


fn demo_swap() {
  var a = 1
  var b = 2
  (a, b) = (b, a)
  print("a={a} b={b}")
}
demo_swap()

// expected: a=2 b=1


// RHS-first means the right side reads the OLD values — one-line

// state steps without temporaries (here: a Fibonacci step).

fn demo_step() {
  var x = 1
  var y = 1
  (x, y) = (y, x + y)
  (x, y) = (y, x + y)
  print("fib={y}")
}
demo_step()

// expected: fib=3


// Unpack a call result into existing vars; `_` discards.

fn minmax() -> (int, int) {
  return (3, 9)
}

fn demo_unpack() {
  var lo = 0
  var hi = 0
  (lo, hi) = minmax()
  print("lo={lo} hi={hi}")
  (lo, _) = (42, 99)
  print("lo={lo}")
}
demo_unpack()

// expected: lo=3 hi=9

// expected: lo=42


struct Point {
  x: int,
  y: int,
}

// By name: shorthand `x` writes the var `x`; `x: px` writes `px`.

// Only the fields you NAME are assigned — no need to list them all.

fn demo_named(p: Point) {
  var x = 0
  var py = 0
  Point { x } = p
  Point { y: py } = p
  print("x={x} py={py}")
}
demo_named(Point { x: 3, y: 4 })

// expected: x=3 py=4


// Fields and indexes are valid slots too (any assignable place).

fn demo_places() {
  var p = Point { x: 0, y: 0 }
  (p.x, p.y) = (5, 6)
  print("p={p.x},{p.y}")
}
demo_places()

// expected: p=5,6

Patterns in match

The Type { field } pattern binds fields in match and if let; .. ignores remaining fields and field: local renames during extraction:

Extracting x and y from Point; renaming fields; ignoring the rest with ..

07-destructuring-struct.zolo
Playground
// Feature: Struct destructuring via `match`

// Syntax: `match value { Type { field, other } => ... }`

// When to use: extract multiple fields from a struct in a single

// pattern. In Zolo, struct destructuring happens via `match`

// (or `if let`), not directly in `let`.


struct Point {
  x: int,
  y: int,
}

struct User {
  name: str,
  age: int,
  email: str,
}

// Basic destructure — bindings have the same name as the fields.

let p = Point { x: 3, y: 4 }
match p {
  Point { x, y } => print("x={x} y={y}"),
}

// expected: x=3 y=4


// Rename fields during destructure: `field: new_name`.

match p {
  Point { x: px, y: py } => print("px={px} py={py}"),
}

// expected: px=3 py=4


// Ignore fields with `..` (rest pattern).

let u = User { name: "Alice", age: 30, email: "a@x.com" }
match u {
  User { name, .. } => print("name={name}"),
}

// expected: name=Alice


// `if let` — destructure when the pattern matches.

if let User { email, .. } = u {
  print("email={email}")
}

// expected: email=a@x.com


// Multiple fields extracted.

match u {
  User { name, age, .. } => print("{name} ({age})"),
}
// expected: Alice (30)

Enum destructuring uses the same match, with the variant in dot notation (Enum.Variant(x)). Each variant becomes a separate arm; if let handles the fast path when only one variant matters:

Result.Ok(v) and Result.Err(e) as match arms; if let for the happy path.

08-destructuring-enum.zolo
Playground
// Feature: Enum destructuring via `match` / `if let`

// Syntax: `match v { Enum.Variant(x) => ... }`

// When to use: extract the payload of an enum variant (Result.Ok,

// Option.Some, events with data, etc.). In Zolo, variants use

// dot-syntax: `MyEnum.Variant`, not `MyEnum::Variant`.


use std::Option
use std::Result

enum Result {
  Ok(int),
  Err(str),
}

enum Shape {
  Circle(float),
  Rectangle(float, float),
  Point,
}

// Destructure each variant with its payload.

let r1 = Result.Ok(42)
let r2 = Result.Err("not found")

match r1 {
  Result::Ok(v) => print("ok={v}"),
  Result::Err(e) => print("err={e}"),
}

// expected: ok=42


match r2 {
  Result::Ok(v) => print("ok={v}"),
  Result::Err(e) => print("err={e}"),
}

// expected: err=not found


// Variants with multiple positional fields.

let s = Shape.Rectangle(10.0, 5.0)
match s {
  Shape::Circle(r) => print("circle r={r}"),
  Shape::Rectangle(w, h) => print("rect {w}x{h}"),
  Shape::Point => print("point"),
}

// expected: rect 10x5


// `if let` — fast path for a single variant.

if let Result::Ok(v) = r1 {
  print("got {v}")
}

// expected: got 42


// `if let` with `else` — useful when it does not match.

if let Result::Ok(v) = r2 {
  print("got {v}")
} else {
  print("no value")
}
// expected: no value

Challenge

Start with two existing variables and update them with one destructuring assignment. Then rewrite the update as a let binding and explain why the two forms are not interchangeable.

Search Zolo

9 results

enespt-br