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).

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.

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.

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