Algebraic Effects¶
Algebraic effects let you track and control side effects in your type signatures. Instead of a function silently performing I/O, mutating state, or throwing exceptions, its effect requirements are declared in its type and enforced by the static checker. At runtime, effect operations dispatch to scoped handlers that you install explicitly.
What Are Algebraic Effects?¶
In most languages, side effects are invisible. A function that reads from a database, writes to a log, or throws an exception looks exactly like a pure function in its signature. Algebraic effects make these dependencies explicit:
- Declaration: you define an effect with named operations and their types.
- Annotation: functions declare which effects they may perform via
![Effect]. - Checking: the static analyzer verifies that callee effects are covered by caller effects.
- Handling: at runtime, you provide scoped implementations for effect operations.
This gives you the documentation benefits of checked exceptions, the composability of dependency injection, and the scoping guarantees of dynamic binding -- all in one mechanism.
Defining an Effect¶
Use effect inside a BEGIN block to define an effect with its operations:
use v5.40;
use Typist;
BEGIN {
effect Console => +{
log => '(Str) -> Void',
writeLine => '(Str) -> Void',
};
}
This does three things:
- Registers the effect
Consolein the Typist Registry. - Creates a synthetic namespace with callable subs:
Console::log(...)andConsole::writeLine(...). - Makes
Consoleavailable as an effect label in:sig()annotations.
The hashref maps operation names to type signature strings. Use +{} to disambiguate from a block.
Multiple operations¶
An effect can have any number of operations:
BEGIN {
effect State => +{
get => '() -> Int',
put => '(Int) -> Void',
};
effect Logger => +{
log => '(Str) -> Void',
};
}
Each operation becomes a sub in the effect's namespace: State::get(), State::put($n), Logger::log($msg).
Annotating Effects on Functions¶
Declare which effects a function may perform by adding ![Effect1, Effect2] after the return type in a :sig() annotation:
sub greet :sig((Str) -> Void ![Console]) ($name) {
Console::log("Hello, $name!");
}
sub process :sig((Str) -> Str ![Console, Logger]) ($data) {
Logger::log("Processing: $data");
Console::writeLine("Done");
"result";
}
The ! is part of the effect syntax, not a negation. Read ![Console] as "may perform the Console effect."
Pure functions¶
A function with no ![] clause is treated as pure:
Generic functions with effects¶
All annotation features compose:
sub logged_first :sig(<T>(ArrayRef[T]) -> T ![Console]) ($arr) {
Console::writeLine("taking first element");
$arr->[0];
}
Calling Effect Operations¶
Effect operations are called as qualified subs in the effect's namespace:
Console::log("hello"); # calls the Console effect's log operation
Console::writeLine("world"); # calls the Console effect's writeLine operation
State::put(42); # calls the State effect's put operation
my $val = State::get(); # calls the State effect's get operation
At runtime, each call dispatches to the nearest handler on the handler stack. If no handler is installed, the call dies:
Handling Effects¶
The handle block installs scoped handlers for effect operations:
my $result = handle {
Console::log("hello");
Console::writeLine("world");
42
} Console => +{
log => sub ($msg) { say ">> $msg" },
writeLine => sub ($msg) { print $msg, "\n" },
};
# $result is 42
Key points:
- No comma after the block.
handleuses the(&@)prototype, the same calling convention asmapandgrep. A comma between the block and the effect name silently breaks the call. - Returns the body's result. The return value of the block is the return value of
handle. - Scoped. Handlers are pushed onto a stack when
handleenters and popped when it exits, even if the body throws an exception.
Multiple effect handlers¶
Handle multiple effects in a single handle block:
my @logs;
my $state = 0;
my $result = handle {
Logger::log("starting");
State::put(10);
my $v = State::get();
Logger::log("val=$v");
$v;
} Logger => +{
log => sub ($msg) { push @logs, $msg },
}, State => +{
get => sub () { $state },
put => sub ($n) { $state = $n },
};
# $result is 10
Nested handlers¶
Inner handlers shadow outer handlers for the same effect:
my @outer_log;
my @inner_log;
handle {
Console::log("outer-scope"); # goes to outer handler
handle {
Console::log("inner-scope"); # goes to inner handler
} Console => +{
log => sub ($msg) { push @inner_log, $msg },
};
Console::log("outer-again"); # outer handler active again
} Console => +{
log => sub ($msg) { push @outer_log, $msg },
};
# @outer_log is ("outer-scope", "outer-again")
# @inner_log is ("inner-scope")
Handler cleanup on exceptions¶
Handlers are always popped when the handle block exits, even on exceptions:
eval {
handle {
Console::log("before");
die "boom\n";
} Console => +{
log => sub ($msg) { },
};
};
# Console handler is gone here -- calling Console::log("test") would die
Exception Handling with Exn¶
Exn is a built-in effect with a single operation, throw:
When a handle block includes an Exn handler, it catches exceptions from die and Exn::throw:
my $result = handle {
die "something went wrong\n";
42; # unreachable
} Exn => +{
throw => sub ($err) { "recovered from: $err" },
};
# $result is "recovered from: something went wrong\n"
Without an Exn handler, exceptions propagate normally through handle:
eval {
handle {
die "no handler\n";
} Console => +{
log => sub ($msg) { },
};
};
# $@ is "no handler\n"
Combining Exn with other effects¶
my @logs;
my $result = handle {
Console::log("before");
die "mid-error\n";
Console::log("after"); # unreachable
"normal";
} Console => +{
log => sub ($msg) { push @logs, $msg },
}, Exn => +{
throw => sub ($err) { "recovered" },
};
# @logs is ("before")
# $result is "recovered"
All handlers (Console and Exn) are properly popped after the block completes.
Built-in Effect Labels¶
Three effect labels are pre-registered by the Prelude:
| Label | Description | Operations |
|---|---|---|
IO |
Standard I/O | None (ambient marker) |
Exn |
Exceptions | throw: (Any) -> Never |
Decl |
Type declarations | None (ambient marker) |
These are ambient effects: the static effect checker skips them in inclusion checks. This means:
- A pure function can call
say,print,warn,die,eval, etc. without an effect mismatch. - You can annotate functions with
![IO]or![Exn]for documentation, but it is not required.
Perl builtins are annotated with their appropriate effects in the Prelude. For example, say is (...Any) -> Bool ![IO], die is (...Any) -> Never ![Exn], and eval is (Any) -> Any ![Exn].
Overriding Builtin Effect Annotations¶
Use declare to override a builtin's effect annotation. This is useful when you want stricter effect tracking:
# Make say require the Console effect instead of ambient IO
declare say => '(Str) -> Void ![Console]';
sub greet :sig((Str) -> Void ![Console]) ($name) {
say "Hello, $name"; # OK: Console is declared
}
sub bad :sig((Str) -> Void) ($name) {
say "Hello, $name"; # EffectMismatch: say requires Console
}
Effect Checking Rules¶
The static effect checker enforces these rules:
Rule 1: Pure cannot call effectful¶
An annotated function without effects cannot call a function with non-ambient effects:
sub effectful :sig((Str) -> Str ![Console]) ($x) { $x }
sub pure_fn :sig((Str) -> Str) ($x) {
effectful($x); # EffectMismatch: pure_fn() has no effect annotation
}
Rule 2: Callee effects must be a subset of caller effects¶
sub needs_ab :sig(() -> Void ![A, B]) () { ... }
sub has_a :sig(() -> Void ![A]) () {
needs_ab(); # EffectMismatch: missing effect 'B'
}
sub has_abc :sig(() -> Void ![A, B, C]) () {
needs_ab(); # OK: {A, B} is a subset of {A, B, C}
}
Rule 3: Unannotated callers are skipped¶
Unannotated functions are not checked for effects. This is the gradual typing principle:
Rule 4: Unannotated callees are treated as pure¶
When an annotated function calls an unannotated function, the callee is treated as having no effects:
sub helper ($x) { $x } # unannotated -- treated as pure
sub main :sig((Str) -> Str ![Console]) ($s) {
helper($s); # OK: helper is pure
}
Suppressing diagnostics¶
Use # @typist-ignore on the line before a call to suppress effect mismatch diagnostics:
sub pure_fn :sig((Str) -> Str) ($s) {
# @typist-ignore
effectful($s); # No EffectMismatch reported
$s;
}
Per-Effect Generics¶
Effects can be parameterized with type variables using bracket syntax:
When used in annotations, provide the concrete type argument:
sub increment :sig(() -> Int ![State[Int]]) () {
my $n = State::get();
State::put($n + 1);
$n + 1;
}
The static checker treats State[Int] and State[Str] as distinct effect labels. A function annotated with ![State[Int]] cannot satisfy a caller expecting ![State[Str]].
Bounded Type Parameters¶
Effect type parameters can have bounds, exactly like generic functions and structs:
The static checker validates type arguments against bounds at the annotation site:
sub ok_fn :sig(() -> Int ![Counter[Int]]) () { ... } # OK: Int <: Num
sub bad_fn :sig(() -> Str ![Counter[Str]]) () { ... } # Error: Str does not satisfy bound Num
Typeclass constraints work the same way:
effect 'Logger[T: Show]' => +{ log_val => '(T) -> Void' };
sub ok :sig(() -> Void ![Logger[Int]]) () { ... } # OK: instance Show Int exists
Scoped Effects¶
Name-based effects (State::get(), Console::log()) use a single global handler stack per effect name. This means you cannot have two independent State instances in the same scope. Scoped effects solve this with identity-based dispatch.
Creating a scoped capability¶
Use scoped to create a capability token for a parameterized effect:
Each call returns a unique Typist::EffectScope object. Even two tokens of the same effect type are independent:
Calling operations on scoped effects¶
Instead of State::get(), call operations as methods on the capability token:
Handling scoped effects¶
Pass the capability token (instead of a string name) to handle:
my $counter = scoped 'State[Int]';
my $state = 0;
my $result = handle {
$counter->put(42);
$counter->get();
} $counter => +{
get => sub { $state },
put => sub ($v) { $state = $v },
};
# $result is 42, $state is 42
Independent instances¶
The key benefit: multiple instances of the same effect with independent state.
my $a = scoped 'State[Int]';
my $b = scoped 'State[Int]';
my ($state_a, $state_b) = (0, 0);
handle {
handle {
$a->put(10);
$b->put(20);
$a->get(); # 10, not 20
$b->get(); # 20, not 10
} $b => +{
get => sub { $state_b },
put => sub ($v) { $state_b = $v },
};
} $a => +{
get => sub { $state_a },
put => sub ($v) { $state_a = $v },
};
Mixing scoped and name-based handlers¶
Scoped and name-based handlers coexist in the same handle block:
my $counter = scoped 'State[Int]';
my $state = 0;
my @log;
handle {
Logger::log("before");
$counter->put(99);
Logger::log("after: " . $counter->get());
} $counter => +{
get => sub { $state },
put => sub ($v) { $state = $v },
},
Logger => +{
log => sub ($msg) { push @log, $msg },
};
Exception cleanup¶
Scoped handlers are cleaned up on exceptions, just like name-based handlers:
my $counter = scoped 'State[Int]';
eval {
handle {
$counter->put(1);
die "boom\n";
} $counter => +{
get => sub { 0 },
put => sub ($) { },
};
};
# Handler is gone — $counter->get() would die "No scoped handler..."
A Complete Example¶
use v5.40;
use Typist;
BEGIN {
effect Console => +{
writeLine => '(Str) -> Void',
readLine => '() -> Str',
};
effect State => +{
get => '() -> Int',
put => '(Int) -> Void',
};
}
sub increment :sig(() -> Int ![State]) () {
my $n = State::get();
State::put($n + 1);
$n + 1;
}
sub run :sig(() -> Void ![Console, State]) () {
Console::writeLine("Count: " . increment());
Console::writeLine("Count: " . increment());
Console::writeLine("Count: " . increment());
}
# Wire up the handlers and execute
my $counter = 0;
handle {
run();
} Console => +{
writeLine => sub ($msg) { say $msg },
readLine => sub () { <STDIN> =~ s/\n\z//r },
}, State => +{
get => sub () { $counter },
put => sub ($n) { $counter = $n },
};
# Output:
# Count: 1
# Count: 2
# Count: 3
Summary¶
| Concept | Syntax |
|---|---|
| Define effect | effect Name => +{ op => 'sig', ... } |
| Parameterized effect | effect 'State[S]' => +{ get => '() -> S' } |
| Call operation | Name::op(args) |
| Annotate effects | :sig((Params) -> Return ![E1, E2]) |
| Handle effects | handle { body } E => +{ op => sub { ... } } |
| Scoped capability | my $ref = scoped 'State[Int]' |
| Scoped call | $ref->get(), $ref->put(42) |
| Scoped handle | handle { body } $ref => +{ op => sub { ... } } |
| Catch exceptions | handle { body } Exn => +{ throw => sub ($e) { ... } } |
| Override builtin | declare say => '(Str) -> Void ![Console]' |
| Suppress check | # @typist-ignore |
Next: Effect Protocols -- add state machine verification to your effects.