effects
α1effects
effects are the backbone of ferrule. every form of non-local control flow (error propagation, async suspension, resource cleanup, generators) is an effect. this unifies control flow under a single model.
no effects clause means pure. you can tell from the signature what a function does.
syntax
types go in <>, effects go in []:
function process<T>(input: T, cap io: Io) -> T effects [alloc, io, fail<ProcessError>] {
// ...
}a function with no effects clause is pure:
function add(x: i32, y: i32) -> i32 {
return x + y;
}effect taxonomy
control effects (built-in)
| effect | meaning |
|---|---|
fail<E> | function may fail with error type E |
suspend | function may pause and resume (async) |
diverge | function may not return (never) |
resource effects (need capabilities)
| effect | capability | what it does |
|---|---|---|
fs | Fs | file system operations |
net | Net | network operations |
io | Io | stdin/stdout/stderr |
time | Clock | time access, sleep |
rng | Rng | randomness |
computation effects (no capabilities needed)
| effect | what it does |
|---|---|
alloc | memory allocation |
atomics | atomic memory operations |
cpu | privileged cpu instructions |
simd | simd intrinsics |
ffi | foreign function calls |
fail<E> replaces error E
errors are an effect now, composable with everything else.
old:
function readFile(path: String, cap fs: Fs) -> Bytes error IoError effects [fs] {
// ...
}new:
function readFile(path: String, cap fs: Fs) -> Bytes effects [fs, fail<IoError>] {
// ...
}check, ok, err, ensure all still work. the difference is conceptual: failure is just another effect in the unified model.
subset rule
for any call g() inside f, the effects of g must be a subset of f's effects:
function caller() effects [fs] {
netCall(); // error: requires [net], not in [fs]
}this is the core enforcement. you can't sneak effects past the caller.
effects vs capabilities
they're related but different.
effects are markers. they say "this function might do io" or "this function might allocate". they're compile-time information.
capabilities are values. they're the authority to actually do the io. you pass them around.
the relationship:
| effect | capability | explanation |
|---|---|---|
fs | Fs | need Fs cap to do fs effect |
net | Net | need Net cap to do net effect |
io | Io | need Io cap to do io effect |
time | Clock | need Clock cap to do time effect |
rng | Rng | need Rng cap to do rng effect |
alloc | none | just marks allocation |
atomics | none | just marks atomic ops |
if a function has effects [fs], it must have a cap fs: Fs somewhere in the call chain. this is checked by the capability flow lint.
purity
a pure function has no effects clause at all. functions with fail<E> are not pure. error propagation is a form of control flow.
function add(x: i32, y: i32) -> i32 {
return x + y; // pure
}effect inference
within a module, the compiler can infer effects from the function body. public symbols must spell them out:
// private, inference ok
function helper(cap io: Io) {
io.println("hello"); // inferred: effects [io]
}
// public, must be explicit
pub function api(cap io: Io) -> Unit effects [io] {
helper(io);
}exports without explicit effects are rejected.
higher-order functions and effect polymorphism
when you take a function as parameter, you need to handle its effects. ...E spreads effects from the parameter into the caller:
function map<T, U, E>(items: View<T>, f: (T) -> U effects E) -> Vec<U>
effects [alloc, ...E]
{
// ...
}whatever effects f has, map also has, plus alloc.
scoped effect handlers
scope blocks create effect boundaries. these are not full algebraic effects; no continuations. they're scoped effect handlers, limited to lexical scope, compiled to structured control flow (gotos + cleanup). simple enough for a systems language.
retry
scope retry(max: 3) {
const conn = check net.connect(host, port)
return check conn.read()
} on fail(e) {
if attempts < max {
clock.sleep(backoff(attempts))
retry
} else {
propagate e
}
}timeout
scope timeout(Duration.seconds(5), clock) {
const data = check fetch(url, net)
return process(data)
} on timeout {
return fallback_data()
}transaction
scope transaction(db) {
check db.insert(record1)
check db.insert(record2)
} on fail(e) {
db.rollback()
propagate e
}async via effects
suspend is an effect. no async/await keywords. no function coloring.
function fetch(url: Url, cap net: Net) -> Response
effects [net, suspend, fail<NetError>]
{
const conn = check net.connect(url.host, url.port)
return check conn.readAll()
}scope handlers determine how suspend is resolved:
scope parallel { ... }: runs children concurrentlyscope race { ... }: returns first to completescope blocking { ... }: runs event loop synchronously
generators via effects
yield is an effect. for is the handler:
function fibonacci() -> i32 effects [yield<i32>] {
var a = 0; var b = 1
loop { yield a; const next = a + b; a = b; b = next }
}
for n in fibonacci() {
// ...
}what's planned
| feature | status |
|---|---|
| effect syntax and subset rule | α1 |
fail<E> effect | α1 |
| standard resource/computation effects | α1 |
| effect inference (private functions) | α1 |
effect polymorphism (...E spread) | α2 |
scoped effect handlers (scope ... on) | α2 |
suspend effect (async) | β |
yield effect (generators) | β |