Algebraic Effects

Osprey treats effects as first-class language features. An effect declares a set of operations; functions list the effects they may perform; handlers give meaning to operations. The compiler rejects any program that performs an unhandled effect.

Status

Effect declarations, perform expressions, effect annotations on function types, handler parsing, and full compile-time unhandled-effect checking are implemented. Continuation/resume semantics inside handlers are not yet implemented; current handlers act as value substitutions, which is sufficient for many uses but does not yet model the full algebraic-effects calculus.

Keywords

effect perform handle in

Effect Declarations

effectDecl ::= docComment? "effect" IDENT "{" opDecl* "}"
opDecl     ::= IDENT ":" fnType
effect State {
    get : fn() -> int
    set : fn(int) -> unit
}

Effectful Function Types

A function declares the effects it may perform with !E after its return type. E is either a single effect or a bracketed set.

fn read() -> string !IO = perform IO.readLine()
fn fetch(url: string) -> string ![IO, Net] = ...

A function with no !E is pure; calling an effectful function from a pure context is a compilation error.

Performing Operations

performExpr ::= "perform" IDENT "." IDENT "(" args? ")"
fn incrementTwice() -> int !State = {
    let current = perform State.get()
    perform State.set(current + 1)
    perform State.get()
}

If no enclosing handler covers an effect, the program does not compile.

Handlers

handlerExpr ::= "handle" IDENT handlerArm+ "in" expr
handlerArm  ::= IDENT paramList? "=>" expr
handle State
    get        => 42
    set newVal => print("set to " + toString(newVal))
in
    incrementTwice()

The innermost matching handler wins for each effect. Handlers may be nested freely:

handle Logger
    log msg => print("[OUTER] " + msg)
in
    handle Logger
        log msg => print("[INNER] " + msg)
    in
        perform Logger.log("test")    // prints "[INNER] test"

Effect Inference

The compiler infers the minimal effect set of every expression. Functions either declare their effects or are required to be pure. A function may be polymorphic over an effect set:

fn loggedCalculationE>(x: int) -> int !E = {
    perform Logger.log("calculating")     // E must include Logger
    x * 2
}

Static Safety Checks

The compiler enforces three static checks on effect programs. Each failure is a compile-time error, not a runtime fault.

Check Failure mode in other languages
Every perform has a handler Runtime crash / unhandled exn
No circular effect dependency Stack overflow
No handler that performs the same effect it handles Infinite loop

Circular Dependency Example

effect StateA { getFromB: fn() -> int }
effect StateB { getFromA: fn() -> int }

fn circularA() -> int !StateA = perform StateA.getFromB()
fn circularB() -> int !StateB = perform StateB.getFromA()

handle StateA
    getFromB => circularB()       // ❌ circular dependency
in
    handle StateB
        getFromA => circularA()   // ❌ circular dependency
    in
        circularA()

Handler-Self-Recursion Example

effect Counter { increment: fn(int) -> int }

fn performIncrement(n: int) -> int !Counter = perform Counter.increment(n)

handle Counter
    increment n => performIncrement(n + 1)   // ❌ handler performs the effect it handles
in
    performIncrement(5)

Worked Example

x * 2 returns Result<int, MathError>; the function below performs Exception on overflow and State to record the success.

effect Exception { raise: fn(string) -> unit }
effect State     { get: fn() -> int, set: fn(int) -> unit }

fn doubleAndStore(x: int) -> int ![Exception, State] = match x * 2 {
    Success { value }   => {
        perform State.set(value)
        value
    }
    Error   { message } => {
        perform Exception.raise(message)
        0
    }
}

handle Exception
    raise msg => { print("error: " + msg); -1 }
in
    handle State
        get        => 0
        set newVal => print("state: " + toString(newVal))
    in
        let result = doubleAndStore(21)
        print("result: " + toString(result))