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:
| Effect | Performed by |
|---|---|
log | log(msg) |
fail[E] | raise e with e: E, or calling a function that raises E |
div | a while loop, which may not terminate |
fs, io, proc, time, env | files, stdin and stderr, child processes, the clock, environment variables and arguments |
conc | atomics and channels (Concurrency) |
net | HTTP clients and servers (Networking) |
db | SQLite databases (Databases) |
rand | keys, 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.