Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Effects and Errors

Every function says what it does besides returning a value. The list after ! is its effect row, and the checker makes it exact: performing an effect the row doesn’t declare is an error, and declaring one the body never performs is a warning. A function with no ! is pure. Effects flow through calls and lambdas, so a caller of a function that logs also has log in its row, unless something handles it.

The built-in effects:

EffectPerformed by
loglog(msg)
fail[E]raise e with e: E, or calling a function that raises E
diva while loop, which may not terminate
fs, io, proc, time, envfiles, stdin and stderr, child processes, the clock, environment variables and arguments
concatomics and channels (Concurrency)
netHTTP clients and servers (Networking)
dbSQLite databases (Databases)
randkeys, tokens, salts and UUIDs (Security and Crypto)
db.read[S], db.write[S]reading and writing the store S in a service
yield[T]yield(x) in a generator

The effects reference has the full table, including the systems and GPU effects.

Raising and catching errors

An error is a value of a type you declare, usually a sum type. raise performs fail[E], and so does ok_or on an Opt that is none:

type StockErr = Unknown{sku: Str} | Short{sku: Str, have: Int}

type Item = {sku: Str, qty: Int}

fn reserve(stock: List[Item], sku: Str, n: Int) -> List[Item] ! fail[StockErr]
= do
  it = stock.find(_.sku == sku).ok_or(Unknown{sku})
  if it.qty < n then raise Short{sku, have: it.qty}
  stock.map(i => if i.sku == sku then i with qty := i.qty - n else i)

catch runs an expression and handles the errors it raises, one arm per variant. When the arms cover every variant, fail[E] leaves the row, so try_reserve is pure. A function that lets errors through just declares fail[E] too:

fn try_reserve(stock: List[Item], sku: Str, n: Int) -> Str
= catch "reserved, {reserve(stock, sku, n).find(_.sku == sku).map(_.qty).or(0)} left"
  | Unknown{sku} => "no such item: {sku}"
  | Short{have} => "only {have} left"

fn checkout(stock: List[Item]) -> List[Item] ! fail[StockErr]
= do
  a = reserve(stock, "pen", 2)
  reserve(a, "ink", 1)

A catch whose only arm is _ handles any error, even when the expression raises several error types. A catch with constructor arms handles that one type and lets the others through.

A test can catch errors the same way. The arms have the type of the caught expression, so the comparison goes inside:

test short = catch reserve([Item{sku: "a", qty: 1}], "a", 2) == []
  | Short{have} => have == 1
  | _ => false

Library functions that touch the outside world return Res and perform their own effect, so you match on the result:

fn line_count(path: Str) -> Int ! fs
= match read_file(path)
  | ok(text) => text.split("\n").len
  | err(_) => 0
$ sspur run errors.ssp
reserved, 3 left
only 5 left
no such item: cap
checkout failed on ink
true 0

Your own effects

You can declare an effect with its operations and let a handler decide what they mean. Here the cart logic asks for prices without knowing where they come from:

// The cart logic asks for prices but does not know where they come from.
effect price_of(sku: Str) -> Int

type Line = {sku: Str, qty: Int where _ > 0}

fn total(lines: List[Line]) -> Int ! price_of
= lines.map(l => price_of(l.sku) * l.qty).sum

// A handler answers the request and resumes the computation.
fn with_catalog(lines: List[Line]) -> Int
= handle total(lines)
  | price_of(sku) => resume(if sku == "pen" then 3 else 12)

fn main() -> Unit ! log
= log("total: {with_catalog([Line{sku: "pen", qty: 4}, Line{sku: "book", qty: 1}])}")

// A test handles the same effect with a stub.
fn flat(lines: List[Line]) -> Int
= handle total(lines)
  | price_of(_) => resume(10)

test flat_price = flat([Line{sku: "a", qty: 2}, Line{sku: "b", qty: 3}]) == 50

handle runs an expression and answers each price_of it performs. resume(v) continues the computation with v as the operation’s result. A handled effect leaves the signature, so with_catalog and flat are pure. The program and the test give different answers to the same question, without a mock framework or an interface.

Forgetting both the handler and the declaration is a compile error:

effect price_of(sku: Str) -> Int

type Line = {sku: Str, qty: Int where _ > 0}

fn total(lines: List[Line]) -> Int ! price_of
= lines.map(l => price_of(l.sku) * l.qty).sum

// Forgot the handler, and the signature says nothing about prices.
fn receipt(lines: List[Line]) -> Str
= "total: {total(lines)}"
$ sspur run effects.ssp
total: 24
$ sspur test effects.ssp
1 passed, 0 failed
$ sspur check unhandled.ssp
unhandled.ssp:10:12 E_EFFECT_MISSING performs 'price_of' but the signature does not declare it
  hint: add 'price_of' to the effect row of receipt

Handlers for built-in effects

log is an ordinary effect with one operation, so a handler can capture it. yield[T] makes a generator, and a for loop over it runs once per yielded value:

fn noisy(n: Int) -> Int ! log
= do
  log("doubling {n}")
  n * 2

fn quiet(n: Int) -> (Int, List[Str])
= do
  var lines = []
  r = handle noisy(n)
    | log(m) => do
      lines := lines.push(m)
      resume()
  (r, lines)
fn evens(limit: Int) -> Unit ! yield[Int]
= do
  for i in 0..limit
    if i % 2 == 0 then yield(i)

fn sum_evens(limit: Int) -> Int
= do
  var s = 0
  for x in evens(limit)
    s := s + x
  s

An arm may resume at most once. An arm that doesn’t resume ends the whole handle with its own value, which is how you stop a generator early. Next: Contracts and Tests.