ferrule

effects

α1
effect-syntaxeffect-subset-rulestandard-effectsfail-effecteffect-inferencescoped-handlers (α2)effect-polymorphism (α2)suspend-effect (β)yield-effect (β)

effects

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)

effectmeaning
fail<E>function may fail with error type E
suspendfunction may pause and resume (async)
divergefunction may not return (never)

resource effects (need capabilities)

effectcapabilitywhat it does
fsFsfile system operations
netNetnetwork operations
ioIostdin/stdout/stderr
timeClocktime access, sleep
rngRngrandomness

computation effects (no capabilities needed)

effectwhat it does
allocmemory allocation
atomicsatomic memory operations
cpuprivileged cpu instructions
simdsimd intrinsics
ffiforeign 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:

effectcapabilityexplanation
fsFsneed Fs cap to do fs effect
netNetneed Net cap to do net effect
ioIoneed Io cap to do io effect
timeClockneed Clock cap to do time effect
rngRngneed Rng cap to do rng effect
allocnonejust marks allocation
atomicsnonejust 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 concurrently
  • scope race { ... }: returns first to complete
  • scope 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

featurestatus
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)β

On this page