Skip to content

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

def identity[T](value: T) -> T:
    return value

def first[T](items: list[T]) -> T:
    return items[0]

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# operator or 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