Skip to content

Generic Variance (in and out Type Parameters)

Implementation status: ✅ Implemented in v0.2.6 (Phase 12.2). TypeParameterDef.Variance property, VarianceValidator (SPY0417–SPY0419), and C# in/out keyword emission fully working.

Generic variance allows substitution of generic types based on their type arguments' inheritance relationships. Sharpy supports covariance (out) and contravariance (in) annotations on interface and delegate type parameters.

Overview

Variance Keyword Data Flow Substitution Rule
Covariant out Type flows out (returned) More derived → base allowed
Contravariant in Type flows in (consumed) Less derived → derived allowed
Invariant (none) Both directions Exact match required

Covariance (out T)

A type parameter marked out can only appear in output positions (return types). This enables safe substitution with more derived types.

interface IProducer[out T]:
    """Produces values of type T."""
    def get(self) -> T: ...
    def peek(self) -> T?: ...

# Covariance in action
class DogProducer(IProducer[Dog]):
    def get(self) -> Dog:
        return Dog("Buddy")

    def peek(self) -> Dog?:
        return self._next_dog

# Dog is a subtype of Animal, so IProducer[Dog] is a subtype of IProducer[Animal]
producer: IProducer[Animal] = DogProducer()  # ✅ OK: covariant
animal = producer.get()  # Returns Dog, but typed as Animal

Why this is safe: When you ask for an Animal, receiving a Dog is always valid because Dog is-an Animal.

Valid out Positions

interface ICovariant[out T]:
    # ✅ Valid: T in return position
    def get(self) -> T: ...
    def get_optional(self) -> T?: ...
    def get_list(self) -> list[T]: ...  # Assuming list is covariant

    # ✅ Valid: T in covariant nested position
    def get_producer(self) -> IProducer[T]: ...

    # ❌ Invalid: T in parameter position
    # def set(self, value: T): ...  # ERROR: T is covariant

    # ❌ Invalid: T in contravariant nested position  
    # def get_consumer(self) -> IConsumer[T]: ...  # ERROR

Contravariance (in T)

A type parameter marked in can only appear in input positions (parameters). This enables safe substitution with less derived types.

interface IConsumer[in T]:
    """Consumes values of type T."""
    def accept(self, value: T): ...
    def process(self, items: list[T]): ...

# Contravariance in action
class AnimalHandler(IConsumer[Animal]):
    def accept(self, value: Animal):
        print(f"Handling: {value.name}")

    def process(self, items: list[Animal]):
        for item in items:
            self.accept(item)

# Animal is a supertype of Dog, so IConsumer[Animal] is a subtype of IConsumer[Dog]
handler: IConsumer[Dog] = AnimalHandler()  # ✅ OK: contravariant
handler.accept(Dog("Rex"))  # AnimalHandler can handle any Animal, including Dog

Why this is safe: A handler that can process any Animal can certainly process a Dog, since Dog is-an Animal.

Valid in Positions

interface IContravariant[in T]:
    # ✅ Valid: T in parameter position
    def accept(self, value: T): ...
    def process(self, items: list[T]): ...

    # ✅ Valid: T in contravariant nested position
    def set_producer(self, producer: IProducer[T]): ...  # Flipped!

    # ❌ Invalid: T in return position
    # def get(self) -> T: ...  # ERROR: T is contravariant

    # ❌ Invalid: T in covariant nested position
    # def get_consumer(self) -> IConsumer[T]: ...  # ERROR: double flip = covariant

Invariance (Default)

Without a variance annotation, a type parameter is invariant and can appear in any position, but generic types are not substitutable.

interface IMutable[T]:
    """Both produces and consumes T — must be invariant."""
    def get(self) -> T: ...
    def set(self, value: T): ...

# Invariant types require exact match
mutable: IMutable[Animal] = SomeMutable[Animal]()  # ✅ OK: exact match
# mutable: IMutable[Animal] = SomeMutable[Dog]()   # ❌ ERROR: Dog ≠ Animal

Why invariance is required: If IMutable[Dog] were assignable to IMutable[Animal], you could call set(Cat()) on what's actually a Dog container — type safety violation.

Multiple Type Parameters

Each type parameter can have independent variance:

interface IConverter[in TInput, out TOutput]:
    """Converts input to output."""
    def convert(self, input: TInput) -> TOutput: ...

# Converter[Animal, Dog] can substitute for Converter[Dog, Animal]
# - in TInput: Animal → Dog (contravariant: accept more general)
# - out TOutput: Dog → Animal (covariant: return more specific)
converter: IConverter[Dog, Animal] = SomeConverter[Animal, Dog]()  # ✅ OK

Delegates with Variance

Delegates (function types) also support variance annotations:

# Covariant delegate — returns T
delegate Producer[out T]() -> T

# Contravariant delegate — accepts T
delegate Consumer[in T](value: T) -> None

# Mixed variance
delegate Transformer[in TIn, out TOut](input: TIn) -> TOut

# Usage
dog_producer: Producer[Dog] = lambda: Dog("Max")
animal_producer: Producer[Animal] = dog_producer  # ✅ Covariant

animal_consumer: Consumer[Animal] = lambda a: print(a.name)
dog_consumer: Consumer[Dog] = animal_consumer  # ✅ Contravariant

Built-in Variant Types

Common .NET interfaces and delegates have variance annotations:

Type Variance Notes
IEnumerable[out T] Covariant Read-only iteration
IReadOnlyList[out T] Covariant Read-only indexed access
IReadOnlyCollection[out T] Covariant Read-only collection
IComparer[in T] Contravariant Compares T values
IComparable[in T] Contravariant Compares to T
IEquatable[in T] Contravariant Equality with T
Action[in T] Contravariant Consumes T
Func[out T] Covariant Produces T
Func[in T, out R] Mixed Transforms T to R
Predicate[in T] Contravariant Tests T

Restrictions

Variance annotations are only valid on: - Interface type parameters - Delegate type parameters

Classes and structs cannot have variant type parameters:

# ✅ Valid: interface with variance
interface IReadable[out T]:
    def read(self) -> T: ...

# ❌ Invalid: class cannot have variance
# class Reader[out T]:  # ERROR: variance not allowed on classes
#     ...

# ❌ Invalid: struct cannot have variance
# struct Wrapper[out T]:  # ERROR: variance not allowed on structs
#     ...

Variance and Constraints

Variance annotations can be combined with type constraints:

interface IAnimalProducer[out T: Animal]:
    """Produces animals of type T."""
    def produce(self) -> T: ...

interface IComparableConsumer[in T: IComparable[T]]:
    """Consumes comparable values."""
    def compare(self, a: T, b: T) -> int: ...

C# Emission

# Sharpy
interface IProducer[out T]:
    def get(self) -> T: ...

interface IConsumer[in T]:
    def accept(self, value: T): ...

interface IConverter[in TIn, out TOut]:
    def convert(self, input: TIn) -> TOut: ...

delegate Factory[out T]() -> T
delegate Handler[in T](value: T) -> None
// C# 9.0
public interface IProducer<out T>
{
    T Get();
}

public interface IConsumer<in T>
{
    void Accept(T value);
}

public interface IConverter<in TIn, out TOut>
{
    TOut Convert(TIn input);
}

public delegate T Factory<out T>();
public delegate void Handler<in T>(T value);

Implementation: ✅ Native — direct mapping to C# variance keywords. - out Tout T (C# covariance) - in Tin T (C# contravariance) - Variance validation performed at compile time (SPY0417–SPY0419) - Position checking enforced by VarianceValidator

Compiler Validation

The compiler validates variance annotations by checking each usage of a variant type parameter:

  1. Covariant (out T) — T must only appear in:
  2. Return types
  3. out parameters
  4. Covariant positions of other types

  5. Contravariant (in T) — T must only appear in:

  6. Parameter types (not out)
  7. Contravariant positions of other types

  8. Nested variance flips:

  9. Covariant in contravariant = contravariant
  10. Contravariant in contravariant = covariant
  11. Covariant in covariant = covariant
  12. Contravariant in covariant = contravariant

Error example:

interface IBroken[out T]:
    def set(self, value: T): ...  
    # ERROR: Type parameter 'T' is covariant but appears in contravariant position

Declaration-Site vs Usage-Site Enforcement

Sharpy enforces variance at two levels, with different strictness:

Declaration-site enforcement (strict)

The VarianceValidator (SPY0417-SPY0419) enforces strict variance rules at declaration sites -- that is, when defining interfaces and delegates with in/out type parameters. A covariant (out) type parameter must not appear in contravariant positions (parameter types), and vice versa. Nested variance flipping is correctly applied (e.g., a contravariant type parameter inside a contravariant position flips back to covariant). This is checked at compile time and produces errors for violations.

Usage-site enforcement (bidirectional)

At assignment sites, when checking whether one function type is assignable to another (including function-to-delegate assignment), the compiler uses bidirectional parameter compatibility: a parameter type match succeeds if either type is assignable to the other. This is more permissive than strict contravariance, which would require only the target's parameter type to be assignable to the source's.

This is a deliberate design choice. Strict contravariant checking at usage sites would reject common patterns like assigning an (Animal) -> None to a (Dog) -> None variable, even though the assignment is safe in practice for non-mutable function references. The bidirectional approach simplifies callback and handler patterns while the declaration-site checks ensure that generic type definitions remain sound.

Return types use standard covariant checking at both levels.

See Function Types -- Function Type Compatibility for examples.

See Also