Skip to content

Collection Types

Sharpy Type Shorthand .NET Type Notes
list[T] [T] Sharpy.List<T> Mutable list
dict[K, V] {K: V} Sharpy.Dict<K, V> Hash map
set[T] {T} Sharpy.Set<T> Unique elements; see set operators
frozenset[T] Sharpy.FrozenSet<T> Immutable, hashable set; no shorthand
tuple[T1, T2, ...] (T1, T2, ...) System.ValueTuple<T1, T2, ...> Fixed-size tuple; supports positional access

With the exception of tuple[...], Sharpy collection types use custom Pythonic wrappers around the corresponding .NET collection types.

Shorthand Syntax

All collection types support shorthand syntax for more concise type annotations:

# These pairs are equivalent:
items: [int]           # items: list[int]
scores: {str: int}     # scores: dict[str, int]
unique: {int}          # unique: set[int]
point: (int, int)      # point: tuple[int, int]

Optional and Error Handling Conventions

Collection access follows these conventions for optionality and error handling:

Operation Return Type Behavior
dict.get(key: K) V? Returns Some(value) or None()
dict[key] V Throws KeyError if missing
list[i] T Throws IndexError if out of bounds
list.get(index: int) T? Returns Some(value) or None()
d: dict[str, int] = {"x": 1, "y": 2}

# Safe access - returns Optional
val: int? = d.get("x")      # Some(1)
val: int? = d.get("z")      # None()

# Direct access - throws on missing key
val: int = d["x"]           # 1
val: int = d["z"]           # KeyError

# List safe access
items: list[str] = ["a", "b", "c"]
item: str? = items.get(0)   # Some("a")
item: str? = items.get(99)  # None()

# List direct access - throws on out of bounds
item: str = items[0]        # "a"
item: str = items[99]       # IndexError

Set and Frozenset Operators

set[T] and frozenset[T] support the same four set operations and the same four subset/superset comparisons, and every one of them accepts the other type as its right operand. Every value in this section was executed against the compiler.

The left operand decides the result type

A mixed operation follows CPython's left-operand rule: the type of the left operand is the type of the result. set | frozenset is a set; frozenset | set is a frozenset. The rule is about the operand types alone — it does not depend on which side is larger, or on the annotation of the variable being assigned.

In the table below, stands for whichever operator the row names:

Operator Meaning set ∘ set set ∘ frozenset frozenset ∘ set frozenset ∘ frozenset
\| union set[T] set[T] frozenset[T] frozenset[T]
& intersection set[T] set[T] frozenset[T] frozenset[T]
- difference set[T] set[T] frozenset[T] frozenset[T]
^ symmetric difference set[T] set[T] frozenset[T] frozenset[T]
< <= > >= subset/superset bool bool bool bool
== != equality bool refused (SPY0222) refused (SPY0222) bool

The comparisons answer a question about the two operands rather than building a collection, so they return bool in every cell and the left-operand rule has nothing to decide.

Neither operand is mutated

A set operation builds a new collection; it never writes through either operand, whichever side the mutable one is on. With a set on the left, the result is a set:

def main() -> None:
    s: set[int] = {1, 2}
    f: frozenset[int] = frozenset([2, 3])

    print(s | f)      # {1, 2, 3}
    print(s & f)      # {2}
    print(s - f)      # {1}
    print(s ^ f)      # {1, 3}
    print(s <= f)     # False

    print(s)          # {1, 2}             — unchanged
    print(f)          # frozenset({2, 3})  — unchanged

The same operands the other way round produce the same elements in a frozenset:

def main() -> None:
    s: set[int] = {1, 2}
    f: frozenset[int] = frozenset([2, 3])

    print(f | s)      # frozenset({1, 2, 3})
    print(f & s)      # frozenset({2})
    print(f - s)      # frozenset({3})
    print(f ^ s)      # frozenset({1, 3})
    print(f >= s)     # False

s - f is {1} and f - s is frozenset({3}) because difference is not symmetric; the elements differ for the usual reason, and only the type of each result comes from the left-operand rule.

Augmented assignment rebinds

|=, &=, -= and ^= are defined by the binary operator plus a rebinding of the left-hand name. Nothing is mutated in place, so the left operand may be a frozenset — the name is simply bound to the new frozenset the operator returned:

def main() -> None:
    s: set[int] = {1, 2}
    f: frozenset[int] = frozenset([2, 3])

    s |= f
    print(s)          # {1, 2, 3}

    g: frozenset[int] = frozenset([1, 2])
    g |= {2, 3}
    print(g)          # frozenset({1, 2, 3})

Because the result type follows the left operand, an augmented assignment always rebinds the name to its own type: s stays a set[int] and g stays a frozenset[int].

Mixed == and != are refused

CPython compares a set and a frozenset by their elements, so {1} == frozenset([1]) is True. Sharpy refuses the comparison in both directions:

def main() -> None:
    s: set[int] = {1}
    f: frozenset[int] = frozenset([1])

    # ERROR SPY0222: Type 'set[int]' does not support operator '=='
    #                with operand of type 'frozenset[int]'
    print(s == f)
    # ERROR SPY0222: Type 'frozenset[int]' does not support operator '=='
    #                with operand of type 'set[int]'
    print(f == s)
    # ERROR SPY0222: Type 'set[int]' does not support operator '!='
    #                with operand of type 'frozenset[int]'
    print(s != f)

This is the one gap in the mixed matrix, and it is a deliberate trade rather than an oversight: equality operators take nullable operands, so a mixed == overload would make the ordinary someSet == None ambiguous between the two candidates. Converting one side states which comparison is meant, and both spellings hold:

def main() -> None:
    s: set[int] = {1}
    f: frozenset[int] = frozenset([1])

    print(s == set(f))          # True
    print(frozenset(s) == f)    # True

Catalogued as a deviation (docs/deviations.yaml, mixed-set-frozenset-equality), which carries the full rationale.

Dict and frozendict

dict[K, V] and frozendict[K, V] share exactly one operator, |, and it obeys the same left-operand rule. Keys on the right win in every cell, which is CPython's PEP 584 rule for dict | dict:

Operator Meaning dict ∘ dict dict ∘ frozendict frozendict ∘ dict frozendict ∘ frozendict
\| merge dict[K, V] dict[K, V] frozendict[K, V] frozendict[K, V]

Neither operand is mutated, exactly as for sets — \| builds a new mapping even when the mutable dict is on the left:

def main() -> None:
    d: dict[str, int] = {"a": 1, "b": 2}
    fd: frozendict[str, int] = frozendict({"b": 20, "c": 30})

    mixed: dict[str, int] = d | fd
    print(mixed)                    # {'a': 1, 'b': 20, 'c': 30}

    frozen_first: frozendict[str, int] = fd | d
    print(len(frozen_first))        # 3
    print(frozen_first["b"])        # 2 — the right operand wins, so d's value survives

    print(d)                        # {'a': 1, 'b': 2}  — unchanged

A frozendict carries dict's insertion order, so these results can equally be printed whole; the examples above read by key only because that is what they are illustrating. A repeated key keeps its first position and takes the last value, and under | a key present on both sides keeps its left-hand position and takes the right-hand value — the same rules dict follows (#1392).

Order is an iteration and rendering property only: == and hashing ignore it, so two frozendicts built from the same pairs in different orders are equal, hash alike, and collapse to one element in a set — while their reprs differ. That is the same split dict has, and it is what keeps a frozendict usable as a dict key.

Both mixed directions were unreachable until #1361: dict was the one builtin collection still registered against System.Collections.Generic.Dictionary<,> rather than its Sharpy.Dict<K, V> wrapper, and the registered CLR type is what operator resolution reflects over. Dictionary declares no operators, so dict | dict resolved only through the shortcut that applies to a lone candidate, and the mixed overload took that shortcut away.

Implementation - ✅ Native - each mixed pairing is a C# operator overload declared on the LEFT operand's type (Sharpy.Set<T> for set ∘ frozenset, Sharpy.FrozenSet<T> for frozenset ∘ set, Sharpy.Dict<K, V> for dict | frozendict, Sharpy.FrozenDict<K, V> for frozendict | dict), which is what makes the left operand decide the result type.

Tuple Positional Access

Tuple elements can be accessed by position using integer literal subscript syntax:

point: tuple[int, int, int] = (10, 20, 30)
x = point[0]   # 10
y = point[1]   # 20
z = point[2]   # 30

Named tuples also support positional access alongside named access:

type Point = tuple[x: float, y: float]
p: Point = (x=3.0, y=4.0)
print(p[0])    # 3.0 (same as p.x)
print(p[1])    # 4.0 (same as p.y)

Restrictions

Rule Behavior
Indices must be integer literals Variable indices are not supported (e.g., t[i] where i is a variable)
No negative indices t[-1] produces a compile-time error (Python divergence)
Compile-time bounds checking t[3] on a 3-element tuple produces a compile-time error

Python Divergence

Unlike Python, Sharpy does not support negative tuple indices. This is because tuple positional access is resolved at compile time (lowered to .Item1, .Item2, etc.), and negative indexing would require runtime tuple length information.

Implementation - 🔄 Lowered - tuple[i] is lowered to .Item{i+1} (e.g., tuple[0].Item1, tuple[1].Item2).

Tuple Slicing

Tuples support slicing with constant non-negative integer bounds only. The result type is a narrower tuple containing the selected element types:

t: tuple[int, str, float, bool] = (1, "hello", 3.14, True)
a: tuple[str, float, bool] = t[1:]     # ('hello', 3.14, True)
b: tuple[int, str] = t[:2]             # (1, 'hello')
c: tuple[str, float] = t[1:3]          # ('hello', 3.14)

Constant references (const N: int = 1; t[N:]) are resolved at compile time via constant folding.

Restrictions

Restriction Reason
Bounds must be constant non-negative integers Tuple element types vary by position; runtime slicing cannot determine the result type
No negative indices Same as tuple positional access — negative normalization needs runtime arity
No step (except 1) Reverse/strided tuple slicing is not supported in v1

Non-constant or negative bounds produce a compile-time error with a diagnostic steering to the constant-bound requirement. (#1609)

Implementation - 🔄 Lowered — t[1:3] on tuple[int, str, float] lowers to ValueTuple.Create(t.Item2, t.Item3).

Slicing

Slicing extracts a contiguous (or strided) subsequence from a collection using start:stop or start:stop:step syntax inside [].

Sliceable Types

Type Result Type Notes
list[T] list[T] New list; original unchanged
str str UTF-16 code-unit slice
bytes bytes Byte-level slice
array[T] list[T] Materializes a list from the slice
ndarray per-axis Multi-axis via SliceSpec; one spec per dimension

Types that are NOT sliceable produce a compile-time error:

  • dict[K, V] — CPython raises KeyError at runtime (slice object is a missing key); Sharpy refuses at compile time.
  • set[T] / frozenset[T] — unordered; slicing is meaningless.
  • tuple[T1, T2, ...] — constant-bound slicing only (see Tuple Slicing below).

A subscript that mixes a comma with a slice (a[i:j, k], a[:, k], a[i, ::2]) is a multi-axis subscript, supported only for ndarray; every other receiver refuses with SPY0602. A comma with no slice (a[i, j]) is not multi-axis: it is an ordinary subscript whose index is the tuple (i, j), so it is refused by the receiver's index-type rule (SPY0220) unless the receiver accepts a tuple index (an ndarray does; a[i, j] reads one element).

xs: list[int] = [1, 2, 3]
print(xs[0, 1])    # SPY0220: Index must be 'int', got 'tuple[int32, int32]'
print(xs[0:2, 1])  # SPY0602: Type 'list[int32]' does not support multi-axis subscripting

Syntax

xs[start:stop]       # elements from start up to (not including) stop
xs[start:stop:step]  # every step-th element
xs[:stop]            # from beginning
xs[start:]           # to end
xs[::step]           # every step-th from beginning to end
xs[::-1]             # reverse

All three bounds — start, stop, step — are optional. An omitted bound is None (absence-of-bound), equivalent to writing None explicitly.

Bound Types

Each slice bound must be assignable to int? (the loose nullable — int or None):

Accepted Example
int literal xs[1:4]
int variable n: int = 2; xs[n:]
None (absent bound) xs[:3], xs[None:3]
int \| None variable n: int \| None = 2; xs[n:]
Refused Diagnostic
bool Slice bound must be 'int' or 'None', got 'bool'
float Slice bound must be 'int' or 'None', got 'float64'
str Slice bound must be 'int' or 'None', got 'str'
bytes Slice bound must be 'int' or 'None', got 'bytes'
int? (Optional[int] ADT) Slice bound must be 'int' or 'None', got 'int32?'

The Optional[int] ADT is distinct from the loose nullable int | None — it does not implicitly cross into a slice bound. Narrow or unwrap the Optional first.

def main():
    xs: list[int] = [10, 20, 30, 40, 50]
    print(xs[1:4])       # [20, 30, 40]
    print(xs[:3])        # [10, 20, 30]
    print(xs[2:])        # [30, 40, 50]
    print(xs[None:3])    # [10, 20, 30]  — None is the absent bound
    print(xs[-2:])       # [40, 50]      — negative indexing
    print(xs[::2])       # [10, 30, 50]  — step
    print(xs[::-1])      # [50, 40, 30, 20, 10] — reverse
    print(xs[4:1:-1])    # [50, 40, 30]  — reverse with bounds

    n: int | None = 2
    print(xs[n:])        # [30, 40, 50]
    print(xs[:n])        # [10, 20]
    n = None
    print(xs[n:])        # [10, 20, 30, 40, 50]

String and bytes slicing follows the same rules:

def main():
    s: str = "hello"
    print(s[1:4])     # ell
    print(s[:3])      # hel
    print(s[::2])     # hlo
    print(s[::-1])    # olleh

    b: bytes = b"abcdef"
    print(b[1:4])     # b'bcd'
    print(b[::-1])    # b'fedcba'

Index Type Rule

Plain subscript access x[i] on int-indexed sequences (list, str, bytes, array) requires an int index. Types that Python allows via implicit conversion are refused:

Refused Diagnostic Python behavior
xs[True] Index must be 'int', got 'bool' Treats as xs[1] (bool subclasses int)
xs["a"] Index must be 'int', got 'str' TypeError at runtime

The bool refusal is a deliberate Type Safety deviation — Python permits xs[True] because bool is a subclass of int, but Sharpy keeps bool and int distinct. The explicit spelling is xs[int(flag)].

Dict Key Type Rule

Dict subscript access d[k], store d[k] = v and augmented store d[k] += v validate the key against the dict's key type parameter using assignability (not strict equality). This means dict[long, V] accepts an int key (widening), and dict[object, V] accepts any key. A T | None receiver is keyed like the dict it holds once narrowed; an Optional[K] key is not a K key and must be unwrapped first.

Expression Key type Outcome
d: dict[str, int]; d[1] int vs str SPY0220 — Dict key must be 'str', got 'int32'
d: dict[str, int]; d[True] bool vs str SPY0220 + bool steer (d[int(flag)])
d: dict[int, str]; k: int? = Some(1); d[k] int? vs int SPY0220 + Unwrap the Optional key first
d: dict[long, str]; d[1] int vs long Accepted (int assignable to long)
o: dict[object, int]; o["k"]; o[2] any vs object Accepted
def main():
    d: dict[long, str] = {}    # a `{1: "a"}` literal is a dict[int, str] — start empty
    d[1] = "a"                 # int key widens to long
    d[1] += "!"
    print(d[1])                # a!

    o: dict[object, int] = {}
    o["k"] = 1
    o[2] = 2
    print(o["k"] + o[2])       # 3

User protocols follow the same rule, per position. A read x[k] validates k against the class's __getitem__ overloads; a store x[k] = v validates k against its __setitem__ overloads and then checks v against the selected overload's value parameter; an augmented store x[k] op= v is both. A key matching no overload is refused with SPY0220 naming the dunder it was checked against and listing the overloads; a class that lacks the dunder the position needs is refused once, by the protocol check (SPY0320), so a __setitem__-only class stores freely and is refused only when read.

class Box:
    def __setitem__(self, k: int, v: str) -> None:
        print(v)

def main():
    b: Box = Box()
    b[1] = "x"        # x — __setitem__ accepts (int, str)
    # b["a"] = "x"    # SPY0220: __setitem__ of 'Box' does not accept a key of type 'str' (overloads: (k: int32, v: str))
    # b[1] = 5        # SPY0220: Cannot assign type 'int32' to 'str'
    # print(b[1])     # SPY0320: Type 'Box' does not support indexing (missing '__getitem__' method).

User Protocol

A user-defined class can support subscript access via __getitem__. Slice support requires a __getitem__ overload that takes a slice parameter:

class MySeq:
    def __getitem__(self, s: slice) -> str:
        return f"{s.start}-{s.stop}-{s.step}"

seq: MySeq = MySeq()
print(seq[1:5:2])  # "1-5-2"

The slice type is a builtin with nullable start, stop, and step properties (each int?). A slice() constructor is available with the same signatures as Python: slice(stop) and slice(start, stop[, step]). (#1610)

__index__ Protocol

Sharpy does not adopt Python's __index__ protocol. In CPython, __index__ lets a value present itself as an integer losslessly (distinct from __int__, which may truncate). Positions that accept it: sequence indexing, slice bounds, range() arguments, and numeral formatters.

The two practical beneficiaries in real Python code — numpy integer scalars and bool — do not create demand in Sharpy: numpy scalars already type as int, and bool in int positions is a deliberate Type Safety deviation. Adopting the protocol without a concrete beneficiary would be the "add X because Python has it" anti-pattern. Full rationale in docs/design/index-protocol-proposal.md (#1611).

Implementation - 🔄 Lowered — x[start:stop:step] lowers to Sharpy.Slice.GetSlice(x, start, end, step) for list, str, bytes, and array. ndarray slicing lowers to x.Slice(new SliceSpec(...)).


Implementation - 🔄 Lowered - Sharpy collections are aliases to the corresponding .NET collections.