Write-Your-Own Generic Types and Functions
Using Generics in Volume 1 ended on a promise: "Writing your own generic types is a topic for the second volume." This is
that chapter. If you write Java you know the shape of the idea and one of its limits, because Java's generics are erased: the
body of List<T> is compiled once, T is Object underneath, and a method that wants a + b on a T simply cannot be
written. If you write C++ you know the other extreme, a template that is checked only when something instantiates it. EK9 sits
between the two. A generic is a template the compiler instantiates for each type you give it, and it checks what it can in the
template and the rest at the instantiation, where it tells you which operator or constructor the type argument lacks. Generic
types and generic functions have equal standing; this chapter writes both, and ends with the constrained types of Volume
1 built on something other than an Integer.
A generic type
A bounded least-recently-used cache is the smallest realistic generic type: it is written once over a key type and a value
type, and it needs to compare, store and evict without knowing either. LruCache of type (K, V) keeps a Dict of entries and
a List that records recency, oldest first.
#!ek9 defines module book.generics.lrucache defines constant DEFAULT_CAPACITY <- 3 defines class LruCache of type (K, V) capacity as Integer: DEFAULT_CAPACITY entries as Dict of (K, V): Dict() recency as List of K: List() default LruCache() LruCache() -> key as K item as V entries += DictEntry(key, item) recency += key limit() -> size as Integer capacity: size put() -> key as K item as V touch(key) entries -= key entries += DictEntry(key, item) if length recency > capacity oldest as K: recency.getOrDefault(0, K()) entries -= oldest recency -= oldest get() -> key as K <- rtn as Optional of V: Optional() if entries contains key touch(key) rtn: Optional(entries.getOrDefault(key, V())) keys() as pure <- rtn as List of K: recency private touch() -> key as K recency -= key recency += key default operator ? defines program CacheDemo() stdout <- Stdout() prices <- LruCache("apple", 0.5) prices.limit(2) prices.put("pear", 0.7) if found <- prices.get("apple") stdout.println(`apple: ${found.get()}`) prices.put("plum", 0.9) stdout.println(`keys, oldest first: ${prices.keys()}`) stdout.println(`pear still cached: ${prices.get("pear")?}`) squares <- LruCache() of (Integer, Integer) for n in 1 ... 6 squares.put(n, n * n) stdout.println(`keys, oldest first: ${squares.keys()}`) if square <- squares.get(5) stdout.println(`5 squared: ${square.get()}`)
It prints:
apple: 0.5
keys, oldest first: ["apple", "plum"]
pear still cached: false
keys, oldest first: [4, 5, 6]
5 squared: 25
Two instantiations appear. LruCache("apple", 0.5) lets the compiler infer (String, Float) from the arguments of the
inferring constructor, and LruCache() of (Integer, Integer) names the types, as List() of String does in Volume 1. Each
is its own concrete type with its own checking; there is no shared erased body. K() and V() build the no-argument instance of
whatever the parameter becomes, which for String is the unset String; in get that never matters, because the lookup is
protected by a contains check.
Exactly two constructors
A generic type declares two constructors and no others: the no-argument one, and the inferring one, which takes one
parameter per type parameter, in order. That is why the capacity is set by the method limit and not by a constructor
argument. A type with only the inferring constructor is refused:
Pair()
->
newKey as K
newItem as V
key: newKey
item: newItem
EK9 : Error : E06040: 'Pair' on line 5 position 4: a generic type requires 2 constructors, default and inferred type
See: ek9 -h E06040
A constructor with the wrong number of parameters, or the right number of the wrong types, is refused too:
Cache()
-> size as Integer
entries := Dict()
EK9 : Error : E06030: 'Cache' on line 10 position 6: type is generic, but for type inference to work; the number of generic and constructor parameters must be the same
See: ek9 -h E06030
Ring()
-> size as Integer
slots += T()
EK9 : Error : E06050: 'size' on line 11 position 11: wrt: 'size': 'Integer' is not 'T': a generic type requires correct constructor argument to match parametric types (and order)
See: ek9 -h E06050
This is the first of the costs. In Java a generic class has as many constructors as you like, and new LruCache<>(capacity) is
the natural way to configure one. Here the constructors exist so that the compiler can infer the type arguments from a call,
and that job needs them to have a fixed shape; anything else you want to say about construction goes in a method.1
Inside a template, every name is typed
Volume 1's chapters, and the primer Where Volume 1 Left You, use <- to declare a name and let the compiler infer its type. Inside a generic
type or function that form is not available, for fields and for local variables alike:
capacity <- DEFAULT_CAPACITY entries <- List() of T
EK9 : Error : E06070: 'capacity' on line 9 position 6: type inference is not supported within generic/template type/functions
See: ek9 -h E06070
EK9 : Error : E06070: 'entries' on line 10 position 6: type inference is not supported within generic/template type/functions
See: ek9 -h E06070
Write capacity as Integer: DEFAULT_CAPACITY and entries as Dict of (K, V): Dict(), as LruCache does. The ban reaches into
a dynamic function written inside a generic body, which is where it surprises people:
byTen as scaler: () is scaler as function product <- amount * 10 rtn: product rtn: byTen(4)
EK9 : Error : E06070: 'product' on line 11 position 6: type inference is not supported within generic/template type/functions
See: ek9 -h E06070
EK9 : Error : E06070: 'product' on line 22 position 8: type inference is not supported within generic/template type/functions
See: ek9 -h E06070
(The first line of that output is the same rule met in a plain function, product <- item * factor, on line 11 of the same
file.) Java's var, Kotlin and Rust all infer locals inside generic code, so this is a real divergence, and the reason is the one
that makes the rest of this chapter hang together: the body is stamped out once per instantiation, and an inferred type is
a place where two instantiations could quietly mean different things. EK9 asks you to write the type down, in the template,
once.
Constraining what T can do
An unconstrained T is an Any: you can hold it and pass it, and little else. To call something on it you constrain it.
Ledger of type T constrain by Priced says every T must be a Priced, which is a trait, and the body may then call
price() on a T and compare two of them with >.
#!ek9 defines module book.generics.ledger defines trait Priced price() as pure abstract <- rtn as Money? operator <=> as pure -> other as Priced <- rtn as Integer: #> price() <=> #> other.price() if rtn == 0 rtn: #< price() <=> #< other.price() operator #? as pure <- rtn as Integer: #?price() defines class Ticket with trait of Priced fare <- Money() default Ticket() as pure Ticket() as pure -> amount as Money fare :=? amount override price() as pure <- rtn as Money: fare default operator ? Meal with trait of Priced cost <- Money() default Meal() as pure Meal() as pure -> amount as Money cost :=? amount override price() as pure <- rtn as Money: cost default operator ? Ledger of type T constrain by Priced items as List of T: List() default Ledger() Ledger() -> first as T items += first add() -> item as T items += item total() <- rtn as Money: 0#GBP for item in items rtn += item.price() dearest() <- rtn as Optional of T: Optional() for item in items keep as Boolean: true if rtn? current as T: rtn.get() keep := item > current if keep rtn: Optional(item) default operator ? defines program PricedLedgers() stdout <- Stdout() tickets <- Ledger(Ticket(45#GBP)) tickets.add(Ticket(12#GBP)) stdout.println(`tickets total: ${tickets.total()}`) meals <- Ledger() of Meal meals.add(Meal(9#GBP)) meals.add(Meal(31#GBP)) meals.add(Meal(18#GBP)) stdout.println(`meals total: ${meals.total()}`) if dearest <- meals.dearest() stdout.println(`dearest meal: ${dearest.get().price()}`)
It prints:
tickets total: 57.00#GBP
meals total: 58.00#GBP
dearest meal: 31.00#GBP
Ticket and Meal are unrelated classes that share one trait, and Ledger works for either. Look at the ordering the trait
declares, because the compiler held it to a standard. The obvious price() <=> other.price() is rejected:
operator <=> as pure -> other as Priced <- rtn as Integer: price() <=> other.price()
EK9 : Error : E07188: '<=>' on line 10 position 15: '<=>' on 'Priced' can answer unset for two set values - 'Money' orders only within one currency or unit, so its '<=>' is unset between them (line 12): an ordering - a '<=>' or a comparator function - must order every two set values of its type: it cannot return an unset Integer, nor build its result on a '<=>' (a field's, a super's, a function's) that answers unset for two set values
See: ek9 -h E07188
A Money orders only within one currency, so its <=> is unset between pounds and dollars; and an ordering that is unset
for two set values would make item > current mean nothing for some ledgers. The compiler proves the trait's ordering is
total before any Ledger is built on it. The version in the listing compares the currency first (#>) and the amount
second (#<), which answers for every pair.
A constraint also puts demands on the type argument. If a body builds a T from another, the type argument must have a
constructor of exactly that signature; and when the constraining type declares constructors, a type argument has to offer them.
A Weight that is an Amount but lacks the copy constructor Amount declares:
sum() of type T constrain by Amount -> first as T second as T <- rtn as Amount: first + second
EK9 : Error : E06130: 'sum' on line 50 position 20: wrt constraining type 'Amount' and parameterizing type 'Weight', constructor 'Weight(Amount)' is required: constraining type constructors must exist on parameterizing type
See: ek9 -h E06130
The report is at the instantiation (sum() of Weight, line 50), not in the template: the template is fine, the type you gave it is
not. The same holds for a body that copies, T(item), which needs the type argument's copy constructor:
Shelf of type T items as List of T: List() default Shelf() Shelf() -> first as T items += T(first) add() -> item as T items += T(item) size() as pure <- rtn as Integer: length items default operator ?
Book here declares Book() -> other as Book, and the shelf holds copies, so changing the original does not change the shelf.
It prints:
on the shelf: 2
first title: Emma
Give the type a constructor that takes a more general parameter instead (Book() -> other as Named) and it is not the
constructor the generic calls, and the compiler says exactly that:
Shelf of type T items as List of T: List() default Shelf() Shelf() -> first as T items += T(first) add() -> item as T items += T(item)
EK9 : Error : E50001: 'Shelf' on line 45 position 15: 'public T <- T(item as T)' is used in 'Shelf of type T', but 'Book(Book)' is not defined in 'Book' - the generic calls exactly that constructor, and 'public Book <- Book(other as Named)' is not it: not resolved
See: ek9 -h E50001
What a bare T demands
Leave T unconstrained and use an operator on it, and the template itself compiles. The error arrives when you instantiate
it for a type that lacks the operator, and it names the operator and the line of the instantiation:
combine() of type T -> first as T second as T <- rtn as T: first + second
EK9 : Error : E07620: 'combine' on line 25 position 23: 'public T <- +(param as T)' is used in 'T <- combine(first as T, second as T) of type T', but '+(Label)' is not defined in 'Label': operator not defined
See: ek9 -h E07620
combine() of Integer on the line before is accepted; combine() of Label is refused because Label has no +. That is
C++'s model, and it is the second cost this chapter owns. Rust requires you to state the bound (T: Add<Output = T>) and
reports a missing one at the definition, which finds the mistake earlier and in the author's code; EK9 lets an unconstrained
template be as permissive as the instantiations turn out to need, and reports at the use. Constraining with constrain by
moves the check back to the template for everything the constraining type offers.
| Language | Is the bound stated? | A missing operation is reported |
|---|---|---|
| Java | Yes, T extends Bound; otherwise T is an Object |
in the generic body, at the definition |
| Rust | Yes, a trait bound is required | in the generic body, at the definition |
| C++ | No (concepts are optional) | at the instantiation |
| EK9 | Optional: constrain by |
at the instantiation, naming the operator and the line |
Generic functions
A generic function is a named construct of its own, not a method with a type parameter attached. It is declared with of type
and made into a value by naming a type, and the value is then called like any function. larger() of type T compares two
values; transformer() of type T as abstract has no body, and a dynamic function supplies one where it is instantiated.
#!ek9 defines module book.generics.functions defines function larger() of type T -> a as T b as T <- rtn as T: a > b <- a : b transformer() of type T as abstract -> item as T <- rtn as T? defines program GenericFunctionDemo() stdout <- Stdout() largerInteger <- larger() of Integer stdout.println(`larger of 21 and 42: ${largerInteger(21, 42)}`) largerString <- larger() of String stdout.println(`larger of pear and apple: ${largerString("pear", "apple")}`) shout <- () is transformer of String as function rtn: item.upperCase() + "!" stdout.println(shout("hello")) doubled <- () is transformer of Integer as function rtn: item * 2 stdout.println(`doubled: ${doubled(33)}`)
It prints:
larger of 21 and 42: 42
larger of pear and apple: pear
HELLO!
doubled: 66
larger() of Integer and larger() of String are two functions made from one template; a > b is checked for each when it is
made. The two () is transformer of ... as function lines are dynamic functions that implement the abstract template for
String and for Integer; Dynamic Functions and Classes is the chapter on how they capture. Because a generic function is a
value, it can be passed to a stream stage or stored, which Java's method-level generics cannot be. The family a made function
belongs to depends on its shape, and Functions as Values: the Type Hierarchy is about that.
A template can also use a from ... to range over its T. A range needs <=>, a copy constructor and ++ on the type, so it
works for Integer, Character and Date:
#!ek9 defines module book.generics.range defines function countFromTo() of type T -> from as T to as T <- rtn as Integer: 0 for step in from ... to rtn++ defines program RangeOverT() stdout <- Stdout() countIntegers <- countFromTo() of Integer stdout.println(`1 to 5: ${countIntegers(1, 5)}`) countCharacters <- countFromTo() of Character stdout.println(`a to e: ${countCharacters('a', 'e')}`) countDates <- countFromTo() of Date stdout.println(`March 2026: ${countDates(2026-03-01, 2026-03-31)}`)
It prints:
1 to 5: 5
a to e: 5
March 2026: 31
and is refused for a String, which has no ++:
countFromTo() of type T -> from as T to as T <- rtn as Integer: 0 for step in from ... to rtn++
EK9 : Error : E07620: 'countFromTo' on line 18 position 22: 'public T <- ++()' is used in 'Integer <- countFromTo(from as T, to as T) of type T', but '++()' is not defined in 'String': operator not defined
See: ek9 -h E07620
A list of circles is not a list of shapes
One more thing a generic type does not do is vary. A List of Circle is not a List of Shape, even though Circle is a
Shape, for the reason Java readers will recognise: if it were, a function holding the List of Shape could add a Square to
what is really a list of circles.
countShapes() as pure -> shapes as List of Shape <- rtn as Integer: length shapes defines program NeverCompiles() stdout <- Stdout() circles <- [Circle(), Circle()] stdout.println(`${countShapes(circles)}`)
EK9 : Error : E06270: 'countShapes' on line 25 position 24: found function 'Integer <- countShapes(shapes as List of Shape)' but used with supplied arguments '(List of Circle)' not appropriate: parameter mismatch
See: ek9 -h E06270
forAI: methodResolution
The message spells out the instantiated signature, countShapes(shapes as List of Shape), and the argument type. The fix is to
build a List() of Shape and add the circles to it. Unlike Java there is no wildcard to say "a list of some kind of shape";
you convert.
Constrained types over a record
Constrained Types in Volume 1 built DrivingAge as Integer constrain as >= 16 and <= 100 and showed the two ways to make one:
the constructor, which Panics on a breach, and .of, which answers unset. The same mechanism works over a record you wrote,
with three differences worth knowing before you try it.
#!ek9 defines module book.generics.constrainedrecord defines type PositiveQuadrant as Point constrain as > 0 Coord as Point defines record Point x as Integer: Integer() y as Integer: Integer() default Point() Point() -> other as Point x: Integer(other.x) y: Integer(other.y) operator > as pure -> limit as Integer <- rtn as Boolean: x > limit and y > limit default operator defines program ConstrainedRecord() stdout <- Stdout() inWindow <- Point() inWindow.x := 5 inWindow.y := 7 accepted <- PositiveQuadrant().of(inWindow) stdout.println(`accepted: ${accepted} set ${accepted?}`) direct <- PositiveQuadrant(inWindow) copied <- PositiveQuadrant() copied :=: direct stdout.println(`copied: ${copied} set ${copied?}`) outside <- Point() outside.x := -1 outside.y := 3 rejected <- PositiveQuadrant().of(outside) stdout.println(`rejected set: ${rejected?}`) alias <- Coord(outside) stdout.println(`alias: ${alias}`)
It prints:
accepted: Point(x=5, y=7) set true
copied: Point(x=5, y=7) set true
rejected set: false
alias: Point(x=-1, y=3)
First, the constraint cannot name x or y. It reaches the base only through an operator the base declares, so the rule
that spans both fields is folded into one: > 0 calls Point's >, which holds when both coordinates exceed the limit.
Second, the base's fields start unset (x as Integer: Integer()), so the constrained type has an unset state to borrow when
.of refuses a value. Third, a constrained value is not a Point and does not expose one: Point(accepted) and accepted.x
are both errors.
accepted <- PositiveQuadrant(inWindow) backToPoint <- Point(accepted) stdout.println(`back: ${backToPoint}`) stdout.println(`x: ${accepted.x}`)
EK9 : Error : E50060: 'Point' on line 36 position 21: In relation to type 'Point', and 'Point(PositiveQuadrant)', parameter mismatch. Possible method(s) public Point <- Point(),public Point <- Point(other as Point): method/function not resolved
See: ek9 -h E50060
forAI: methodResolution
EK9 : Error : E50001: 'x' on line 38 position 36: 'x' on 'PositiveQuadrant': not resolved
See: ek9 -h E50001
That is the same fact as in Volume 1 (an Integer constrained to an age is not an Integer), now over fields. :=: between
two constrained values copies, as the third line of output shows, and Coord as Point is a plain alias that carries no
constraint, which is why Coord(outside) accepts x of minus one. A constrained type declares no constructors of its own;
it is built through the base's copy constructor, so there is nothing to chain.
What it costs
You pay for generics in three places, and none is hidden. You write every field and local type inside a template, which
removes the <- of the rest of the language. You cannot give a generic type a constructor of your own shape, so configuration
moves into methods. And for a T you did not constrain, the compiler's complaint arrives at the line that uses the template,
not the line that wrote it, which is a longer walk than Rust asks of you.
What those three buy is a template whose every instantiation is an ordinary type, resolved and checked like one you wrote by
hand: no erased body, and an error message that names the exact operator, constructor and line. A generic
function is a value you can pass, and a constraint is a trait whose ordering the compiler has proved total. When the cost does
bite, the habit it teaches is the useful one: put the constraint on the template, in constrain by, so the check is made where
the code is.
-
The rule is
ek9 -h E06030: "any other way of building the object belongs in a method or function, not a constructor." The codes seen here are E06040 (the wrong number of constructors), E06030 (the inferring constructor's parameter count) and E06050 (a parameter that is not the matching type parameter). ↩