Advanced Generics
This chapter covers generic library design, method-specific requirements, and
runtime type matching. Read Generics,
Traits and Implementations, and Classes first.
The examples build on the basic type parameters and where clauses from Part 7.
26.1 Generic Extensions
Section titled “26.1 Generic Extensions”You can write extensions that work with generic types. As with other extensions, give the extension a name and import it before using its methods:
package example
import example.ArrayHelpers
extension ArrayHelpers<T> for Array<T> = function isEmpty(self): Bool = self.length == 0
function first(self): Option<T> = if self.isEmpty() then None else Some(self[0])
function main(): Unit = let numbers = [|1, 2, 3|] assert numbers.isEmpty() == false assert numbers.first() == Some(1)26.2 Class Constraints
Section titled “26.2 Class Constraints”Use where T: class when an operation needs class identity:
function sameInstance<T>(left: T, right: T): Bool where T: class = ClassIdentity.equals(left, right)
function instanceHash<T>(value: T): Int64 where T: class = ClassIdentity.hash(value)The bound permits concrete and generic classes, aliases of classes, and generic parameters already constrained by class. A superclass bound such as where T: Animal also satisfies it. A Box<Int32> is a class even though its element type is not.
Records, enums, arrays, newtypes, primitives, Any, and interface objects do not satisfy class. The bound grants neither constructors nor arbitrary members, and it does not automatically implement Equatable or Hashable.
Combine it with trait bounds using where T: class + Display. An unconstrained generic function cannot call the identity functions just because its current callers happen to pass classes. See Class Identity for the equality and hash contracts.
A primitive subtype bound can restrict a method to a particular element type.
For example, where T: Uint8 allows ReadonlySlice<T> to widen to
ReadonlySlice<Uint8>. It accepts Uint8 and the uninhabited subtype Never;
it does not convert other integer types or imply the class constraint.
Stream.writeToOutputStream uses this bound to expose byte output directly on
its generic module.
26.3 Bounds on Enclosing Parameters
Section titled “26.3 Bounds on Enclosing Parameters”A method’s where clause can constrain type parameters declared by its
enclosing module, class, implementation block, or extension block. The method
does not need to declare its own type parameters:
class Holder<T>(public value: T) = public function render(self): String where T: Display = self.value.format() public function unchanged(self): T = self.valuerender requires T: Display; constructing a Holder<T> or calling unchanged
does not. The bound applies throughout the method’s signature, body, and nested
closures, and does not become available to sibling methods.
Callers must satisfy these requirements when calling the method or taking a method reference. Generic callers prove them using their own bounds:
function renderHolder<T>(holder: Holder<T>): String where T: Display = holder.render()A method can use the class’s type parameter together with a type parameter of its own. For example, this box can search the value it holds:
trait Contains<Item> = function contains(self, item: Item): Bool
class SearchBox<T>(public value: T) = public function contains<U>(self, item: U): Bool where T: Contains<U> = self.value.contains(item)Here, T is the type stored in the box. U is the type of item passed to
contains. Read where T: Contains<U> as: the stored type must support
searching for this kind of item.
For a concrete example, a range can contain integers:
record Range = start: Int32, end: Int32
implement Contains<Int32> for Range = function contains(self: Range, item: Int32): Bool = item >= self.start && item <= self.end
let box = SearchBox(Range { start = 1, end = 10 })box.contains(5) // T is Range, U is Int32: allowed; returns truebox.contains(true) // U is Bool: rejected; Range does not implement Contains<Bool>The same box keeps its stored type, Range, across calls. Each call chooses
the method’s U from the item being searched for and checks the corresponding
Contains<U> requirement.
Use different names for the two parameters: declaring contains<T> here would
reuse the class’s T, which the compiler rejects.
26.4 Trait and Override Contracts
Section titled “26.4 Trait and Override Contracts”An implementation must accept the calls that its trait promises to callers. It cannot add an extra requirement to one of those methods. For example, the compiler rejects this implementation:
trait Inspect = function inspect(self): Unit
record Box<T> = value: T
implement <T> Inspect for Box<T> = function inspect(self: Box<T>): Unit where T: Display = ()The implementation says every Box<T> implements Inspect, but its method
would only work for some choices of T. A caller that knows only Inspect
has no way to satisfy that extra requirement.
Putting where T: Display on the implementation block instead restricts
which boxes implement Inspect. Every box that does implement the trait then
has the promised method.
The same rule applies to class overrides: a child method must accept the calls allowed by its parent method. It cannot add an extra bound. A requirement already guaranteed by the enclosing class or implementation block can be repeated on the method.
An implementation also inherits the bounds declared on a generic trait method.
Renaming the method’s type parameter, such as from T to U, does not remove
those requirements.
26.5 Variance and Method Bounds
Section titled “26.5 Variance and Method Bounds”Variance controls whether one use of a generic type can be assigned to another.
For example, every Int32 value can be used as Any, but that does not by itself
mean a Box<Int32> can be used as Box<Any>.
Plain Parameters, out, and in
Section titled “Plain Parameters, out, and in”With a plain parameter, such as Box<T>, different type arguments stay separate:
Box<Int32> cannot be assigned to Box<Any>. This is called invariance.
Use out T when the type provides values of T. A reader that produces integers
can be used where a reader of any values is expected:
class Reader<out T>(public value: T)
let integers = Reader(42)let anyValues: Reader<Any> = integersThis is called covariance. The compiler checks that an out parameter is not
used where a caller could supply a value of that type, such as a method argument
or a mutable field.
Use in T when the type accepts values of T. Something that accepts any value
can also accept integers:
class Consumer<in T>() = public function accept(self, value: T): Unit = ()
let anyValues = Consumer<Any>()let integers: Consumer<Int32> = anyValuesThis is called contravariance. An in parameter cannot be used where the
caller receives a value of that type, such as a method return type. Start with
plain type parameters; add in or out when your API needs these conversions.
Conditional Bounds on Virtual Methods
Section titled “Conditional Bounds on Virtual Methods”There is one restriction when combining these method bounds with in or out
on a class parameter. Consider this hypothetical declaration, which the compiler
rejects:
class Formatter<in T>() = public function render(self, value: T): String where T: Display = value.format()Normally, in T lets you use a Formatter<Any> as a Formatter<Int32>: something
that accepts any value can also accept an integer. But that would cause a problem
here:
let original = Formatter<Any>()let integers: Formatter<Int32> = originalintegers.render(42)The last call appears valid because Int32 implements Display. However, the
object is still the original Formatter<Any>. Its render method is unavailable
because Any does not implement Display. Assigning the object to a differently
typed variable does not create a new object or a new implementation of its method.
A virtual call uses the method belonging to that original object.
To prevent this, use plain T, without in or out, when a virtual method adds
a requirement on T:
class Formatter<T>() = public function render(self, value: T): String where T: Display = value.format()Plain T is called invariant. It prevents the conversion from
Formatter<Any> to Formatter<Int32> shown above. The same restriction applies
to parameters mentioned inside a method’s bound: for where T: Contains<U>,
both class parameters T and U must be invariant if the class does not already
guarantee that requirement.
Alternatively, require Display on the whole class:
class Formatter<in T>() where T: Display = public function render(self, value: T): String = value.format()Now every allowed Formatter<T> has a working render method. You cannot
construct Formatter<Any> in the first place, so the problematic conversion
cannot arise. The class-level bound does not impose the extra invariance rule;
the usual rules for in and out still apply.
26.6 Pattern Matching on Generic Types
Section titled “26.6 Pattern Matching on Generic Types”You can use pattern matching to check types at runtime. This is particularly useful in generic functions where you need to handle different types differently.
Type Matching in Generic Functions
Section titled “Type Matching in Generic Functions”The most common use case is determining the actual type in a generic function:
record Box<T> = value: T
function getBoxType<T>(box: T): Int32 = match box with case _: Box<Int32> => 1 case _: Box<String> => 2 case _ => 0
let intBox = Box { value = 42 }let strBox = Box { value = "hello" }
getBoxType(intBox) // returns 1getBoxType(strBox) // returns 2The _: Type syntax checks if the value has the specified type without binding it to a variable.
Distinguishing User-Defined Types
Section titled “Distinguishing User-Defined Types”Type patterns work with non-generic types too, letting you distinguish between different user-defined types:
record Cat = name: Stringrecord Dog = name: String
function getAnimalType<T>(animal: T): Int32 = match animal with case _: Cat => 1 case _: Dog => 2 case _ => 0
let cat = Cat { name = "Whiskers" }let dog = Dog { name = "Rex" }
getAnimalType(cat) // returns 1getAnimalType(dog) // returns 2Matching Type Arguments
Section titled “Matching Type Arguments”Use the type name with explicit type arguments in a pattern:
record Box<T> = value: T
function describeBox(box: Box<String>): Int32 = match box with case _: Box<String> => 1 case _ => 0
let b = Box { value = "hello" }describeBox(b) // returns 1Matching with Custom Types
Section titled “Matching with Custom Types”You can match on generic types instantiated with user-defined types:
record Person = name: String
record Container<T> = item: T
function checkContainer<T>(c: T): Int32 = match c with case _: Container<Person> => 1 case _ => 0Matching Multiple Type Arguments
Section titled “Matching Multiple Type Arguments”For types with multiple type parameters, specify all type arguments. This allows distinguishing between different instantiations:
record Pair<A, B> = first: A second: B
function getPairType<T>(p: T): Int32 = match p with case _: Pair<Int32, String> => 1 case _: Pair<String, Int32> => 2 case _: Pair<String, String> => 3 case _ => 0
let p1 = Pair { first = 42; second = "hello" }let p2 = Pair { first = "hello"; second = 42 }let p3 = Pair { first = "a"; second = "b" }
getPairType(p1) // returns 1getPairType(p2) // returns 2getPairType(p3) // returns 3Generic Enums
Section titled “Generic Enums”Pattern matching works with generic enums too:
enum Maybe<T> = Just(T) Nothing
function checkMaybe<T>(m: T): Int32 = match m with case _: Maybe<String> => 1 case _: Maybe<Int32> => 2 case _ => 0Generic Classes
Section titled “Generic Classes”And with generic classes:
class Wrapper<T>(value: T) = function get(self): T = self.value
function checkWrapper<T>(w: T): Int32 = match w with case _: Wrapper<String> => 1 case _: Wrapper<Int32> => 2 case _ => 0Distinguishing Different Generic Types
Section titled “Distinguishing Different Generic Types”You can distinguish between different generic types that have the same type argument:
record Box<T> = value: T
record Wrapper<T> = inner: T
function getType<T>(x: T): Int32 = match x with case _: Box<Int32> => 1 case _: Wrapper<Int32> => 2 case _ => 0
let b = Box { value = 42 }let w = Wrapper { inner = 42 }
getType(b) // returns 1getType(w) // returns 2Binding Values in Type Patterns
Section titled “Binding Values in Type Patterns”You can bind the matched value to a variable:
record Box<T> = value: T
function processBox<T>(x: T): Int32 = match x with case box: Box<Int32> => box.value case box: Box<String> => box.value.length case _ => 0
let intBox = Box { value = 42 }let strBox = Box { value = "hello" }
processBox(intBox) // returns 42processBox(strBox) // returns 5Wildcard Fallback
Section titled “Wildcard Fallback”The wildcard pattern _ catches any type that doesn’t match the specific patterns:
record Box<T> = value: T
function boxCode<T>(x: T): Int32 = match x with case _: Box<Int32> => 1 case _: Box<String> => 2 case _ => 99 // Fallback for other types
let b = Box { value = true }boxCode(b) // returns 99 (Box<Bool> falls through to wildcard)26.7 Generic Trait Defaults
Section titled “26.7 Generic Trait Defaults”A default is a method body supplied by the trait. The implementing type can use it by leaving that method out of its implementation block.
In this example, pick<T> is a generic method: each call chooses T.
The first call chooses Int32; the second chooses String. Reader itself has
no type parameters.
package example
trait Picker = function pick<T>(self, value: T): T = value
record Reader = id: Int32
implement Picker for Reader
function main(): Unit = let reader = Reader { id = 0 } assert reader.pick(42) == 42 assert reader.pick("text") == "text"A generic class can use the same default. Here Reader<T> has a class parameter,
and pick<T> has a separate method parameter. Creating Reader(1) fixes the
class parameter to Int32; it does not prevent reader.pick("text") from
choosing String for the method parameter.
package example
trait Picker = function pick<T>(self, value: T): T = value
class Reader<T>(id: T) implements Picker = public function get(self: Reader<T>): T = self.id
function choose<P, T>(picker: P, value: T): T where P: Picker = picker.pick(value)
function main(): Unit = assert choose(Reader(1), "bound") == "bound" let reader = Reader(1) assert reader.pick("text") == "text" assert reader.pick(42) == 42An explicit matching method takes precedence over the default. Defaults may also be properties. Calls from a default to another instance member observe subclass overrides, just like calls from other class methods.
A generic default body can use only the operations justified by its declared
bounds. For example, a method comparing two values with == needs
where T: Equatable. Implementations cannot add stricter requirements than the
trait declares. Generic methods still cannot be dispatched through interface
values; use a generic function with a trait bound instead.
26.8 Associated Outputs
Section titled “26.8 Associated Outputs”An associated type lets each implementation choose a type. Producer below says
that produce() returns its implementation’s Output. NumberProducer chooses
Int32 in its implementation block.
The generic helper does not need to choose that output type. P.Output means
“the output chosen by the Producer implementation for P.”
package example
trait Producer = type Output function produce(self): Output
record NumberProducer = value: Int32
implement Producer for NumberProducer = type Output = Int32 function produce(self): Int32 = self.value
function read<P>(producer: P): P.Output where P: Producer = producer.produce()
function forward<P>(producer: P): P.Output where P: Producer = read(producer)
function main(): Unit = assert forward(NumberProducer { value = 42 }) == 42For NumberProducer, both read and forward return Int32. Another producer
could choose String, and the same helpers would return String for it.
Inside read, the output is still unknown. It can be returned or passed to
another function expecting P.Output. It cannot be treated as Int32 or used
in arithmetic merely because one implementation returns a number.
When a helper specifically needs a numeric output, state that requirement:
function readNumber<P>(producer: P): Int32 where P: Producer<Output = Int32> = producer.produce()Associated names are resolved through the parameter’s trait bounds. If two
bounds give P different members named Output, P.Output is ambiguous.
Associated-type declarations cannot provide defaults: each implementation must
supply its associated definitions.
26.9 Generic Associated Types
Section titled “26.9 Generic Associated Types”Sometimes the implementation chooses a container, while each method call chooses
what goes inside it. That is the purpose of type Wrapped<T>.
Read the following example in three steps:
Wrapperrequires a generic methodwrap<T>and an associated typeWrapped<T>.OptionalWrapperchoosesOption<T>;ArrayWrapperchoosesArray<T>.wrapValuereturns the container chosen by itswrapperargument, containing the type of itsvalueargument.
package example
trait Wrapper = type Wrapped<T> function wrap<T>(self, value: T): Wrapped<T>
record OptionalWrapper = name: Stringrecord ArrayWrapper = name: String
implement Wrapper for OptionalWrapper = type Wrapped<T> = Option<T> function wrap<T>(self, value: T): Option<T> = Some(value)
implement Wrapper for ArrayWrapper = type Wrapped<T> = Array<T> function wrap<T>(self, value: T): Array<T> = [|value|]
function wrapValue<W, T>(wrapper: W, value: T): W.Wrapped<T> where W: Wrapper = wrapper.wrap(value)
function main(): Unit = let optional = OptionalWrapper { name = "optional" } let array = ArrayWrapper { name = "array" } let numberOption: Option<Int32> = wrapValue(optional, 42) let numberArray: Array<Int32> = wrapValue(array, 42) let textOption: Option<String> = wrapValue(optional, "hello") let textArray: Array<String> = wrapValue(array, "hello") assert numberOption.require == 42 assert numberArray == [|42|] assert textOption.require == "hello" assert textArray == [|"hello"|]W.Wrapped<T> names the result without forcing every implementation to use the
same container. In the first call, W is OptionalWrapper and T is Int32,
so the result is Option<Int32>. In the second, W is ArrayWrapper, so the
result is Array<Int32>.
The associated definition does not have to use its parameter. An implementation
could choose type Wrapped<T> = Int32 for every T. Therefore, knowing the
result type does not necessarily tell the compiler the input type. Supply type
arguments when the arguments to the call do not provide enough information.
Generic Resource Helpers
Section titled “Generic Resource Helpers”Usable<T, E> uses an associated type Wrapped<U, E2> for the result of its
continuation. A helper accepting an unknown resource must preserve that result
contract. Its caller can provide a finish function returning the resource’s
wrapper:
package examplenewtype Holder = Int32implement Usable<Int32, Never> for Holder = type Wrapped<U, E2> = U function use<U, E2>(self, f: (Int32) => U, errorF: (Never) => E2): U = f(self.value)function scoped<R>(resource: R, finish: (Int32) => R.Wrapped<Int32, Never>): R.Wrapped<Int32, Never> where R: Usable<Int32, Never> = let value = use resource finish(value)function main(): Unit = assert scoped(Holder(40), value => value + 2) == 42Holder chooses Wrapped<U, E2> = U, so its continuation returns a plain
Int32. The generic scoped function cannot assume that choice for every
resource. Its finish parameter and return type both say
R.Wrapped<Int32, Never> to preserve the contract.
26.10 Inherited Overloads
Section titled “26.10 Inherited Overloads”A child trait can add a method whose name is already used by a parent when the parameter lists differ. The implementation supplies both methods. The argument type determines which one a call selects:
package example
trait TextValue = function value(self, input: String): Int32
trait Value extends TextValue = function value(self, input: Int32): Int32
record Reader = id: Int32
implement Value for Reader = function value(self, input: String): Int32 = 10 function value(self, input: Int32): Int32 = input + 1
function read<R>(reader: R): Int32 where R: Value = reader.value("text") + reader.value(20)
function main(): Unit = let reader = Reader { id = 0 } assert reader.value("text") == 10 assert reader.value(20) == 21 assert read(reader) == 31Here reader.value("text") selects the inherited String method, while
reader.value(20) selects the Int32 method. This also works inside read,
where only the R: Value bound is known.
The overloads can supply separate default bodies. Eligible interface methods retain separate dispatch slots, including after conversion to a parent interface. An implementation missing either required overload is incomplete. Calls matching more than one overload remain ambiguous; changing only the return type does not create a valid overload.
Further Reading
Section titled “Further Reading”Part 25: Tuple Extension (Advanced) applies generic bounds to tuple shapes and recursive trait implementations. Its enclosing-parameter example shows how a method can require a tuple without restricting the whole type.
