EK9 in Depth

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.

  1. 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). ↩