17. Effects of your own: effect and with handle

!io is the one effect the compiler knows about. You can declare your own: a set of operations whose implementation the caller picks at the point of use.

effect Ask {
  fn ask() -> Int
}

fn sum_three() -> Int !Ask = ask() + ask() + ask()

pub fn main() -> Unit !io = {
  with handle Ask { ask() => 42 }
  println(to_string(sum_three()))
}
Open in playground
126

Three things are happening there:

More than one operation, and operations with parameters

An effect may have several operations, and a handler has to answer every one of them — no more and no fewer:

effect Log {
  fn note(msg: String) -> Unit
  fn level() -> Int
}

fn work(n: Int) -> Int !Log = {
  note("working on ${n}")
  n * level()
}

pub fn main() -> Unit !io = {
  with handle Log {
    note(m) => println("[log] ${m}")
    level() => 3
  }
  println(to_string(work(7)))
}
Open in playground
[log] working on 7
21

An arm's body may do anything, io included. That counts against the block that installed the handler — which is why main above is !io — and not against work, which emitted the operation: work owes only !Log.

Who answers: the nearest one lexically

A handler is found by where it is written, not by the runtime stack. An inner one shadows an outer one:

effect Ask {
  fn ask() -> Int
}

fn twice() -> Int !Ask = ask() + ask()

pub fn main() -> Unit !io = {
  with handle Ask { ask() => 1 }
  println(to_string(twice()))
  with handle Ask { ask() => 10 }
  println(to_string(twice()))
}
Open in playground
2
20

An arm that emits its own effect again finds the outer handler — a handler does not answer itself — so with handle Ask { ask() => ask() * 10 } means "take the answer from outside and multiply it by ten", not an infinite loop.

A closure does not keep the handler it was written under. Carry it out of the block and it carries the label with it: its row still says !Ask, and the handler that answers is the one in scope where the closure is finally called. The type says who has to supply a handler, not what any particular arm would have done, so a handler with pure arms leaves you an !Ask closure just as an io one does.

A function type may therefore name an effect, and it means exactly what it reads as. fn(f: fn() -> Int !Ask) says "whoever calls f supplies the Ask handler", which is how the call really works. fn(f: fn() -> Int !e) is the other spelling rather than a workaround: an effect variable takes a closure with any row at all and forwards that row into your own.

Higher-order functions need no change

An effect variable (!e) forwards a named effect along with everything else, so map, fold and for loops carry on as before:

effect Ask {
  fn ask() -> Int
}

fn shifted(xs: List[Int]) -> List[Int] !Ask = map(xs, x => x + ask())

pub fn main() -> Unit !io = {
  with handle Ask { ask() => 100 }
  println(to_string(shifted([1, 2, 3])))
}
Open in playground
[101, 102, 103]

The v1 boundary

The full rules are in spec.en.md §6.5 and the design trade-offs in effects-design.md.


That is every core feature of Dawn. The deeper reference is spec.en.md and the design trade-offs are in design.en.md. Those two are written in Chinese and translated: they are living documents, edited in Chinese by every change to the language, so the Chinese half is the original and the English half is registered against it. The rest of docs/ is monolingual — its reader is the author, and prose that has to be translated before it can be written is prose that does not get written.