Skip to content

Tuple Unpacking

Sharpy supports destructuring tuples and lists into individual variables across multiple contexts: assignments, for loops, and comprehensions.

Simple Tuple Unpacking

Unpack a tuple into variables by matching the number of elements:

point: tuple[int, int] = (3, 7)
x, y = point
print(x)  # 3
print(y)  # 7

The number of targets must match the number of tuple elements:

# ❌ ERROR: Cannot unpack 3 values into 2 variables
a, b = (1, 2, 3)

Implementation - ✅ Native - C# tuple deconstruction: var (x, y) = point;

Redundant Parentheses Around Targets

Parentheses around a binding name never change what it binds — on every store-target route: plain and augmented assignment, annotated declarations, nested tuple elements, starred operands, for and comprehension targets, and with … as targets. The parser canonicalizes the target once, so (a) = 1 is exactly a = 1 (the canonical-form contract, #1170; Python accepts every spelling):

for (x) in [1, 2]:
    print(x)          # 1, 2
for ((a), b) in [(3, 4)]:
    print(a, b)       # 3 4
(c): int = 5
((d), e) = (6, 7)
(f), g = 8, 9
*(h), i = [10, 11, 12]
print(h, i)           # [10, 11] 12
print([y for (y) in [13, 14]])   # [13, 14]

The shapes Python rejects stay rejected: (*a), b = xs ("cannot use starred expression here"), except E as (e), and a parenthesized walrus target ((a) := 1).

List-Display Targets

A list display on the left side of an assignment is accepted as a tuple target — [a, b] = (1, 2) is identical to (a, b) = (1, 2). The parser normalizes the list display to a tuple target at the one store-target seam, so every downstream position (assignment, for, comprehension, with … as) accepts it uniformly:

def main() -> None:
    [a, b] = (1, 2)
    print(a, b)
    for [x, y] in [(3, 4), (5, 6)]:
        print(x, y)
1 2
3 4
5 6

Augmented assignment onto a list display stays refused (Python SyntaxError for both tuple and list displays):

[a, b] += (1, 2)   # error SPY0225: augmented assignment onto a list display target

Starred Targets in All Positions

A starred target (*rest) works in every unpacking position — assignment, for statement, comprehension for clause, and with … as — with one arity rule: a star absorbs zero or more remaining elements. The source can be a tuple or a list[T]:

def main() -> None:
    # Assignment
    first, *rest = [1, 2, 3]
    print(first, rest)

    # For statement
    for a, *tail in [(1, 2, 3), (4, 5, 6)]:
        print(a, tail)

    # Comprehension
    heads = [h for h, *_ in [(10, 20, 30), (40, 50, 60)]]
    print(heads)
1 [2, 3]
1 [2, 3]
4 [5, 6]
[10, 40]

The with … as position takes the same starred target and the same arity rule, including the parenthesized spelling:

class Triple:
    vals: tuple[int, int, int]

    def __init__(self, a: int, b: int, c: int) -> None:
        self.vals = (a, b, c)

    def __enter__(self) -> tuple[int, int, int]:
        return self.vals

    def __exit__(self) -> None:
        pass

def main() -> None:
    with Triple(1, 2, 3) as (a, *rest):
        print(a, rest)
    with Triple(1, 2, 3) as (a, b, *rest):
        print(a, b, rest)
1 [2, 3]
1 2 [3]

An arity error names the position as a suffix — … in with statement, … in for loop, … in comprehension for clause — so one wording covers every position (SPY0239).

The sole-starred group

A group whose only target is the star is legal in its comma spelling (*a,) and its list-display spelling [*a] — both bind the whole sequence to a list, in every position:

def main() -> None:
    (*a,) = (1, 2)
    print(a)               # [1, 2]
    [*b] = (3, 4)
    print(b)               # [3, 4]
    for (*c,) in [(5, 6), (7, 8)]:
        print(c)           # [5, 6] then [7, 8]
[1, 2]
[3, 4]
[5, 6]
[7, 8]

The bare parenthesized form (*a) — no trailing comma, not a list display — is a Python SyntaxError and is refused with Python's own wording, in every position (SPY0225):

def main() -> None:
    xs: list[tuple[int, int]] = [(1, 2)]
    (*a) = xs             # error SPY0225: cannot use starred expression here
    (*a), b = xs          # error SPY0225: cannot use starred expression here
    for (*a) in xs:       # error SPY0225: cannot use starred expression here
        pass

Nested Tuple Unpacking

Targets can themselves be tuple patterns, enabling nested destructuring:

t: tuple[tuple[int, int], int] = ((1, 2), 3)
(a, b), c = t
print(a)  # 1
print(b)  # 2
print(c)  # 3

Nesting can be arbitrarily deep:

t: tuple[tuple[int, tuple[int, int]], int] = ((1, (2, 3)), 4)
(a, (b, c)), d = t
print(a)  # 1
print(b)  # 2
print(c)  # 3
print(d)  # 4

Each nested target must match the structure and element count of the corresponding tuple element.

Stars at nested depth

The star rule holds at every depth: a star inside a nested target absorbs its slice exactly as a top-level star does, in all four positions. Two stars at the same depth are refused with Python's wording (SPY0356, multiple starred expressions):

def main() -> None:
    a, (b, *c) = (1, (2, 3, 4))
    print(a, b, c)                      # 1 2 [3, 4]
    (a2, *rest), d = ((1, 2, 3), 4)
    print(a2, rest, d)                  # 1 [2, 3] 4
    a3, (b3, (c3, *d3)) = (1, (2, (3, 4, 5)))
    print(a3, b3, c3, d3)               # 1 2 3 [4, 5]
    for x, (y, *z) in [(1, (2, 3, 4))]:
        print(x, y, z)                  # 1 2 [3, 4]
1 2 [3, 4]
1 [2, 3] 4
1 2 3 [4, 5]
1 2 [3, 4]
a, (*b, *c) = (1, (2, 3, 4))   # error SPY0356: multiple starred expressions

Implementation - 🔄 Lowered - Temporary variables with .Item1, .Item2, etc. access:

var __t0 = t;
var a = __t0.Item1.Item1;
var b = __t0.Item1.Item2;
var c = __t0.Item2;

Rest Patterns (*rest)

A starred expression collects remaining elements into a list:

items: list[int] = [1, 2, 3, 4, 5]

# Collect tail
first, *rest = items
print(first)  # 1
print(rest)   # [2, 3, 4, 5]

# Collect head
*rest, last = items
print(rest)   # [1, 2, 3, 4]
print(last)   # 5

# Collect middle
first, *mid, last = items
print(first)  # 1
print(mid)    # [2, 3, 4]
print(last)   # 5

Rules: - Only one starred expression is allowed per unpacking - The starred variable is always typed as list[T] where T is the element type of the source - Works with both lists and tuples as the source

# ❌ ERROR: Only one starred expression allowed
*a, *b = items

Implementation - 🔄 Lowered - Index access and slicing:

var __t0 = items;
var first = __t0[0];
var mid = __t0.GetSlice(new global::Sharpy.Slice((int?)1, (int?)-1));
var last = __t0[-1];

Tuple Unpacking in For Loops

Iterate over collections of tuples with destructuring:

pairs: list[tuple[str, int]] = [("alice", 1), ("bob", 2)]
for name, score in pairs:
    print(f"{name}: {score}")
# alice: 1
# bob: 2

Nested unpacking is also supported in for loops:

items: list[tuple[tuple[int, int], str]] = [((1, 2), "a"), ((3, 4), "b")]
for (x, y), label in items:
    print(f"{label}: {x + y}")
# a: 3
# b: 7

Implementation - ✅ Native (simple case) - C# foreach (var (name, score) in pairs) - 🔄 Lowered (nested case) - Temporary loop variable with .Item1, .Item2 access

Tuple Unpacking in Comprehensions

List, set, and dict comprehensions support tuple unpacking in their for clauses:

pairs: list[tuple[int, int]] = [(1, 2), (3, 4), (5, 6)]
sums = [a + b for a, b in pairs]
print(sums)  # [3, 7, 11]

Nested unpacking works in comprehensions as well:

items: list[tuple[tuple[int, int], str]] = [((1, 2), "a"), ((3, 4), "b")]
result: list[str] = [name + ":" + str(x + y) for (x, y), name in items]
print(result)  # ["a:3", "b:7"]

Implementation - 🔄 Lowered - Lambda with temporary variable destructuring:

pairs.Select((__param) => {
    var a = __param.Item1;
    var b = __param.Item2;
    return a + b;
}).ToList()

Type Inference

Unpacking targets are automatically inferred from the source type:

Source Type Target Inference
tuple[int, str] First target: int, second: str
list[tuple[int, str]] (in for loop) Loop targets: int, str
*rest from list[T] Starred target: list[T]
*rest from tuple[T, ...] Starred target: list[T] (uses first element type)

Nested tuple targets recurse into the corresponding element type and validate structure at each level.

Constructing Tuples: No tuple(iterable)

A tuple's arity is part of its type — tuple[int, str] lowers to a .NET ValueTuple with exactly two fields. Python's tuple(iterable) produces a tuple as long as the iterable, which is a runtime property, so there is no type to give the result. The form is rejected (SPY0338):

xs: list[int] = [1, 2, 3]
t = tuple(xs)   # ❌ ERROR SPY0338: variable-length tuple(iterable) is not supported

Use list(...) when the length is a runtime value, or a tuple literal when the arity is known:

xs: list[int] = [1, 2, 3]
ys: list[int] = list(xs)                        # runtime length
t: tuple[int, int, int] = (xs[0], xs[1], xs[2])  # known arity

If the argument is already a tuple, the conversion is redundant — drop the call. Tuple literals, explicitly parameterized construction, and every unpacking form above are unaffected.

Deliberate CPython divergence (Axiom 1/3 over Axiom 2): explicitly parameterized construction converts elements to the written element types — tuple[float, str]((1, "a")) is (1.0, 'a'). CPython's tuple[float, str] is a bare types.GenericAlias whose call ignores the parameters and would keep 1 an int. Sharpy treats the written annotation as the type authority, so the element widens exactly as x: float = 1 does (#1200; pinned in ParameterizedTupleConversionTests).

Element Stores Use the Target's Declared Type

When unpacking a tuple literal into targets that already have a declared type, each element is a store into the target's declared slot. The element expression sees the declared type as its expected type, so constructor shorthands like Some(v) and None() can infer their type parameter from it:

class Box:
    v: int? = None()

def main() -> None:
    b: Box = Box()
    n: int = 0
    b.v, n = Some(5), 1   # Some(5) infers int? from b.v's declared type
    print(b.v, n)          # 5 1

Similarly, bare None into a nullable target adopts the target's type:

def main() -> None:
    x: str | None = "hello"
    x, n = None, 1    # None stores into str | None
    print(x is None)   # True

The R-T payload rule applies to identifier targets that are narrowed: inside a narrowing block (if x is not None:), storing a payload value into a narrowed T? variable wraps it — mirroring the behavior of plain assignments:

def main() -> None:
    d: int? = Some(10)
    if d is not None:
        d, n = 5, 2    # 5 wraps to Some(5) — d stays int?
        print(d, n)     # 5 2

Refusals use the store seam's diagnostic codes: SPY0604 (strict Optional construction), SPY0229 (None into non-nullable), not a generic type-mismatch.

When unpacking creates a fresh binding (a new variable, not a re-assignment to an existing one), a None element is refused — it has no type on its own:

a, b = None, 1  # error: cannot infer a type for 'a': 'None' names no type on its own

Annotate the target or use a declared-slot variable:

a: str | None = None
a, b = None, 1  # OK: a is an existing str | None variable

Error Cases

Scenario Diagnostic
Element count mismatch SPY0239: Cannot unpack N values into M variables
Unpacking a non-tuple type SPY0239: Cannot unpack non-tuple type
Multiple starred expressions SPY0356: Only one starred expression allowed
tuple(iterable) construction SPY0338: variable-length tuple(iterable) is not supported

See Also