Generics¶
Generic Classes¶
class Box[T]:
"""A container for a single value."""
_value: T
def __init__(self, value: T):
self._value = value
def get(self) -> T:
return self._value
def set(self, value: T):
self._value = value
# Usage
int_box = Box[int](42)
str_box = Box[str]("hello")
Implementation
- ✅ Native - class Box<T>
A generic type reference must be constructed (or used where a type is
expected — an annotation, or a type argument like isinstance(x, Box[int])'s
second operand). Uncalled, it is not a value: b = Box[int] is rejected with
SPY0339. It has no value form today; user-class constructor references are
tracked by #1211, and the corresponding builtin references (f = int) are
already legal under the constructor-reference rules — see
Function Types. Wrong-arity
references (Box[int, str] on a one-parameter class) are rejected with the
same diagnostic annotation positions use, after PEP-696 trailing defaults are
filled (Pair[int] with class Pair[K, V = str] constructs Pair<int, string>).
Static members through a constructed type reference¶
A static member — a const, a @static field, a @static method, or a nested
type — is reached through a constructed type reference: the type arguments
must be supplied. Box[int].CAPACITY reads the constant on the closed type
Box<int>; the bare open reference Box.CAPACITY is rejected with SPY0339, the
same rule that applies to using the reference as a value.
class Box[T]:
const CAPACITY: int = 3
@static
def make() -> int:
return 7
def main() -> None:
print(Box[int].CAPACITY) # 3
print(Box[int].make()) # 7
The reference is emitted as the closed type (Box<int>.CAPACITY), never the open
generic. This also holds for a type alias of a constructed reference
(type IntBox = Box[int] then IntBox.CAPACITY) and for a nested type
constructed through the reference (Box[int].Inner() emits new Box<int>.Inner()).
Generic Functions¶
Implementation
- ✅ Native - T Identity<T>(T value)
Members that cannot carry their own type parameters¶
A member's type parameter list is emitted on the member, so a member whose C# form has no
type-parameter list cannot declare one. Two kinds are affected, and both are refused with SPY0705:
__init__, which is emitted as a C# constructor;- every operator dunder (
__add__,__eq__,__neg__,__implicit__, …), each emitted as a C#operatoror conversion operator.
C# makes neither generic (§15.11, §15.10). Declare the type parameter on the type instead, which is where these members can read it:
# ❌ SPY0705 — a constructor cannot be generic
class Box:
def __init__[V](self, v: V) -> None:
pass
# ✅ the type parameter lives on the class
class Box[V]:
value: V
def __init__(self, value: V) -> None:
self.value = value
Every other member kind — module function, instance method, static method, __call__ — carries its
own type parameters normally.
Generic Function Instantiation¶
Generic functions support both type inference and explicit type arguments at call sites:
Type inference (preferred):
# Type inferred from argument
result = identity(42) # T inferred as int
name = identity("hello") # T inferred as str
items: list[int] = [1, 2, 3]
x = first(items) # T inferred as int from list[int]
Explicit type arguments:
# Explicitly specify type parameter
result = identity[int](42) # T explicitly int
name = identity[str]("hello") # T explicitly str
# Useful when inference is ambiguous or impossible
empty = create_empty_list[int]() # No argument to infer from
Multiple type parameters:
def convert[T, U](value: T, converter: (T) -> U) -> U:
return converter(value)
# Explicit type arguments for multiple parameters
result = convert[str, int]("42", int.parse)
# Can also infer from arguments
result = convert("42", int.parse) # T=str, U=int inferred
Partial type argument specification is not supported:
# ❌ Cannot specify only some type parameters
result = convert[str](...) # ERROR: must specify all or none
A generic function reference with explicit type arguments must be called immediately:
Unlike a bare function reference — which is a first-class value (see function_types.md) — a generic function reference with explicit type arguments (e.g. identity[int]) is not a value. It exists only as the callee of an immediate call. It cannot be assigned to a variable, passed as an argument, stored in a collection, or returned.
# ✅ Called immediately (the only legal use)
result = identity[int](42)
result = (identity[int])(42) # parentheses around the callee are fine
# ❌ Used as a value
f = identity[int] # ERROR: a generic function reference must be called
print(identity[int]) # ERROR: cannot pass it as an argument
items = [identity[int]] # ERROR: cannot store it in a collection
return identity[int] # ERROR: cannot return it
To pass a specific instantiation around as a value, wrap it in a lambda (lambda x: identity[int](x)) or reference the generic function without type arguments and let inference close it at each call site.
Type parameters through wrappers (T?, T | None, T!E):
A parameter that reaches T through a wrapper binds T from the argument's payload. An argument that carries no payload — None(), a bare None — binds nothing: it is refused with the explicit-syntax steer rather than binding T to its own absence. A written type argument closes the slot, and the payload-less argument is then fine.
def f[T](x: T?) -> T:
return x.unwrap()
def g[T](x: T | None) -> str:
return str(x)
def r[T](x: T!str) -> T:
return x.unwrap()
class Box[T]:
v: T?
def __init__(self, v: T?) -> None:
self.v = v
print(f[int](Some(5))) # 5
print(f(Some(5))) # 5 — T bound to int from the payload
print(g[str](None)) # None
print(g("a")) # a
print(r(Ok(5))) # 5
print(r[int](Ok(6))) # 6
print(Box[int](Some(5)).v) # 5
f(None()) # ❌ SPY0237: Type parameter 'T' cannot be inferred; no arguments provide type information. Use explicit syntax: f[T](...)
g(None) # ❌ SPY0237 — same steer
Once inference (or a written type argument, or a sibling argument, or the base reference of super().__init__) closes T, every argument is checked against the closed slot exactly as at a non-generic callee: f[int](1) is refused because 1 is not an int? (SPY0604), f[int8](300) because 300 does not fit int8 (SPY0220), and the closed slot is what the emitted C# binds. T | None closed at a value type is that value type (there is no Nullable<T> behind an unconstrained T), so g[int](None) is refused (SPY0229: cannot assign None to non-nullable int32).
Implementation
- ✅ Native - identity<int>(42) in C#
- Type arguments use [] in Sharpy, lowered to <> in C#
- An inferred instantiation is emitted with its type arguments written out (F<sbyte>(xs, 7)), so Roslyn binds the instantiation the checker closed rather than re-inferring it.
Type Constraints¶
interface IComparable[T]:
def __lt__(self, other: T) -> bool: ...
def find_max[T: IComparable[T]](items: list[T]) -> T:
"""Find the maximum item (must be comparable)."""
max_item = items[0]
for item in items:
if max_item < item:
max_item = item
return max_item
| Constraint | C# Equivalent |
|---|---|
T: Interface |
where T : Interface |
T: class |
where T : class |
T: struct |
where T : struct |
T: notnull |
where T : notnull |
T: new() |
where T : new() |
class Counter:
count: int
def __init__(self):
self.count = 7
def pick[T: new()](items: list[T]) -> T: # where T : new()
return items[0]
def describe[T: notnull](x: T) -> str: # where T : notnull
return str(x)
def main():
print(pick([Counter()]).count) # 7
print(describe(42)) # 42
Implementation - ✅ Native - Direct mapping to C# generic constraints.
Multiple Type Constraints¶
A type parameter can have multiple constraints using the & syntax:
# Single constraint
def compare[T: IComparable[T]](a: T, b: T) -> int:
return a.compare_to(b)
# Multiple constraints with &
def sort_and_hash[T: IComparable[T] & IHashable](items: list[T]) -> int:
"""Sort items and return combined hash."""
sorted_items = sorted(items)
return hash(tuple(sorted_items))
# Multiple constraints on class
class SortedSet[T: IComparable[T] & IEquatable[T]]:
_items: list[T]
def add(self, item: T):
# Can use both comparison and equality
pass
Constraint combinations:
| Sharpy Syntax | C# Equivalent |
|---|---|
T: IFoo |
where T : IFoo |
T: IFoo & IBar |
where T : IFoo, IBar |
T: class & IFoo |
where T : class, IFoo |
T: struct & IFoo |
where T : struct, IFoo |
T: class & IFoo & IBar |
where T : class, IFoo, IBar |
For variance annotations on type parameters (out T for covariance, in T for contravariance), see Generic Variance.
Order matters for class/struct:
When combining class or struct with interface constraints, class/struct should come first:
# ✅ Preferred: class/struct first
def process[T: class & IDisposable](item: T): ...
# ✅ Also valid: interfaces only
def process[T: IDisposable & ICloneable](item: T): ...
# The compiler reorders constraints to match C# requirements
Implementation
- ✅ Native - & constraints lower to comma-separated C# where clause
- T: IFoo & IBar → where T : IFoo, IBar