Effect System
AILANG uses an algebraic effect system with capability-based security. All side effects must be declared in function signatures and granted at runtime.
AILANG's effect system builds on foundational research:
- Plotkin & Pretnar (2009): Handlers of Algebraic Effects — Core theory of algebraic effects
- Leijen (2014): Koka: Row Polymorphic Effect Types — Row polymorphism for effects
- Lucassen & Gifford (1988): Polymorphic Effect Systems — Original effect typing
See Citations & Bibliography for complete references.
Browse the Live Demos to see effects working in the browser: AI effect (Claude Chat, Gemini Live), IO effect (DocParse), and contract-verified effect boundaries (Safe Agent).
Why Effects?
Traditional languages hide side effects:
# Python - side effects are invisible
def process_data():
data = read_file("input.txt") # FS access hidden
print("Processing...") # IO hidden
return transform(data)
AILANG makes effects explicit:
-- AILANG - effects declared in signature
func processData() -> Data ! {IO, FS} {
println("Processing...");
let data = readFile("input.txt");
transform(data)
}
Benefits:
- Reasoning: Know exactly what a function can do from its type
- Security: Grant only needed capabilities at runtime
- Testing: Mock effects for deterministic testing
- AI-Friendly: Models can verify effect safety statically
Available Effects
IO Effect
Console input/output operations.
import std/io (println, print, readLine)
func greet(name: string) -> () ! {IO} {
print("Hello, ");
println("${name}!")
}
Builtins:
| Function | Type | Description |
|---|---|---|
println | string -> () ! {IO} | Print with newline |
print | string -> () ! {IO} | Print without newline |
readLine | () -> string ! {IO} | Read line from stdin |
readLineOpt | () -> Option[string] ! {IO} | Blank/final partial lines are Some; EOF is sticky None |
exit | int -> () ! {IO} | Terminate this program with an exit code (see below) |
Native terminal input
std/terminal uses the existing IO capability. info() reports configured input
and output TTY status and optional physical dimensions. withTerminal(options, body) activates noncanonical, no-echo input for its callback, and returns
Result[a, TerminalError]. The callback's effects propagate through ! {IO, e}.
readEvent(session, timeout_ms) returns typed key, resize, EOF, interruption or
idle events. Timeouts are -1 (block), 0 (poll), or 1..60000 milliseconds;
Idle is separate from EOF. Session constructors grant no authority: the host
rejects stale and cross-context handles, nested acquisition, competing line
readers and pending buffered input. No operation opens another terminal device.
macOS/Linux support this API in the evaluator and native bytecode VM. Windows
and WASM return Unsupported; Go code generation rejects terminal operations
at compile time. The CLI restores terminal state before signal termination.
Embedders must inject their process-lifecycle hook to enable native scopes.
An error from native activation must be handled explicitly by the application.
Exit code
exit(code) is how a CLI written in AILANG fails. It lives in std/io (not std/process,
which runs other programs), needs only --caps IO, never returns, and flushes
telemetry/traces before the process ends. Shipped v0.10.1 (M-EXIT-CODE).
import std/io (println, exit)
export func main() -> () ! {IO} {
match validate(args) {
Ok(()) => println("ok"),
Err(msg) => {
println("error: ${msg}");
exit(1)
}
}
}
| Surface | exit(0) | exit(n≠0) |
|---|---|---|
ailang run | process exits 0 | process exits n (OS applies & 0xFF) |
ailang run --batch | that item succeeds | that item fails with program called exit(n); remaining items still run (v0.33.1, #607) |
serve-api route / A2A / MCP handler | success (unit result) | request fails — HTTP 500 / task failed / MCP error program called exit(n); the server keeps running (#706) |
Do not use exit() to report a value — return it. A runtime panic also exits non-zero,
so an exit-code-only test cannot tell "found a negative answer" from "crashed"; print the
reason before exiting.
FS Effect
File system operations.
import std/fs (readFile, writeFile, exists)
func copyFile(src: string, dst: string) -> () ! {FS} {
let content = readFile(src);
writeFile(dst, content)
}
Builtins:
| Function | Type | Description |
|---|---|---|
readFile | string -> string ! {FS} | Read file contents |
writeFile | (string, string) -> () ! {FS} | Write content to file |
fileExists | string -> bool ! {FS} | Check if file exists |
listDir | string -> [string] ! {FS} | List directory entries (sorted) |
mkdir | string -> () ! {FS} | Create a single directory |
mkdirAll | string -> () ! {FS} | Create directory tree (mkdir -p) |
isDir | string -> bool ! {FS} | Check if path is a directory |
isFile | string -> bool ! {FS} | Check if path is a regular file |
removeFile | string -> () ! {FS} | Remove file or empty directory |
_zip_listEntries | string -> Result[List[string], string] ! {FS} | List ZIP archive entries |
_zip_readEntry | (string, string) -> Result[string, string] ! {FS} | Read text entry from ZIP |
_zip_readEntryBytes | (string, string) -> Result[string, string] ! {FS} | Read binary entry from ZIP (base64) |
FS Sandbox
Restrict all FS operations to a directory by setting the AILANG_FS_SANDBOX environment variable:
AILANG_FS_SANDBOX=/tmp/sandbox ailang run --caps FS --entry main program.ail
When set, all file paths are resolved relative to the sandbox root:
-- With AILANG_FS_SANDBOX=/tmp/sandbox:
readFile("data.txt") -- reads /tmp/sandbox/data.txt
writeFile("out.txt", s) -- writes /tmp/sandbox/out.txt
listDir("subdir") -- lists /tmp/sandbox/subdir
This applies to every FS builtin (readFile, writeFile, readFileBytes, writeFileBytes, appendFile, appendFileBytes, fileExists, listDir, mkdir, mkdirAll, isDir, isFile, removeFile) and all std/zip operations. It also sets the working directory for std/process exec.
If unset (the default), paths resolve normally from the process working directory.
- Eval benchmarks: isolate generated code to a temp directory
- Agent workspaces: confine AI agents to their workspace
- Untrusted code: prevent access to files outside the sandbox
Clock Effect
Time operations with deterministic mode support.
import std/clock (now, sleep)
func timedOperation() -> int ! {Clock, IO} {
let start = now();
sleep(1000); -- Sleep 1 second
let end = now();
println("Elapsed: ${show(end - start)}ms");
end - start
}
Builtins:
| Function | Type | Description |
|---|---|---|
now | () -> int ! {Clock} | Current monotonic time (ms) |
sleep | int -> () ! {Clock} | Sleep for milliseconds |
Deterministic Mode:
# Fixed time for reproducible tests
ailang run --clock-mode fixed --clock-start 1000 program.ail
Net Effect
HTTP operations with security protections.
-- Simple: httpGet/httpPost return body string directly
import std/net (httpGet, httpPost)
func fetchData(url: string) -> string ! {Net} =
httpGet(url)
func postData(url: string, body: string) -> string ! {Net} =
httpPost(url, body)
-- Advanced: httpRequest returns Result[HttpResponse, NetError]
-- HttpResponse = {status: int, headers: List[{name, value}], body: string, ok: bool}
import std/net (httpRequest)
import std/json (decode)
func fetchJson(url: string) -> Result[Json, string] ! {Net} =
match httpRequest("GET", url, [], "") {
Ok(resp) => decode(resp.body), -- resp.body is the body string
Err(Transport(msg)) => Err("network error: ${msg}"),
Err(_) => Err("request failed")
}
Common mistake:
Ok(resp)captures the fullHttpResponserecord, not just the body. Useresp.bodyto get the response body string. To parse JSON, dodecode(resp.body)notdecode(resp).
Builtins:
| Function | Type | Description |
|---|---|---|
httpGet | string -> string ! {Net} | HTTP GET, returns body string directly |
httpPost | (string, string) -> string ! {Net} | HTTP POST, returns body string directly |
httpRequest | (string, string, List[{name, value}], string) -> Result[HttpResponse, NetError] ! {Net} | Full HTTP with headers, status codes, structured errors |
httpRequestBytes | (string, string, List[{name, value}], bytes) -> Result[HttpResponse, NetError] ! {Net} | Same as httpRequest but body is raw bytes (image/PDF/binary upload). Defaults Content-Type: application/octet-stream. |
HttpResponse includes both body: string (UTF-8 view, lossy for binary) and bodyBytes: bytes (raw payload, always exact). Use bodyBytes for binary downloads.
Security Features:
- DNS rebinding prevention
- Private IP blocking (10.x.x.x, 192.168.x.x, etc.)
- HTTPS enforcement (configurable)
CLI Flags:
| Flag | Meaning |
|---|---|
--net-allow-domains <list> | Domain allowlist (comma-separated). |
--net-allow-http | Permit http:// URLs (default: https only). |
--net-allow-localhost | Permit loopback (127.0.0.1, localhost) — with --net-allow-http, this is how a program talks to a local model server such as ollama. |
--net-allow-metadata | Permit the cloud metadata address (169.254.169.254). |
--net-timeout <duration> | Per-request timeout for every Net call (httpGet, httpPost, httpRequest*). Default 30s. A local 27B model answering in 45s needs e.g. --net-timeout 5m; on timeout the call returns Err(Transport("... context deadline exceeded")). Mirrors --process-timeout. |
--net-max-bytes <size> | Cap on each response body (httpGet, httpPost, httpRequest*, std/web). Default 5MB; AILANG_NET_MAX_BYTES sets it when the flag is absent (run and serve-api). Sizes take K/M/G suffixes (50MB); zero or malformed is an error. Over the cap, httpRequest* returns Err(BodyTooLarge(<cap>)) and httpGet/httpPost fail with E_NET_BODY_TOO_LARGE. The body is read whole into memory (once as bytes, once as the body string), so budget at least twice the cap per in-flight response. Needs --caps Net; refused with --policy. |
Env Effect
Environment variable access.
import std/env (getEnv)
func getConfig() -> string ! {Env} {
match getEnv("CONFIG_PATH") {
Some(path) => path,
None => "/etc/default.conf"
}
}
Builtins:
| Function | Type | Description |
|---|---|---|
getEnv | string -> Option[string] ! {Env} | Get environment variable |
Process Effect
Execute external commands with capability-based security. (This effect runs other
programs. To set this program's own exit code — a CLI that must fail non-zero — use
exit(code) from std/io, under the IO effect; see Exit code below.)
import std/process (exec)
import std/bytes (toString)
func runCommand(cmd: string, args: [string]) -> () ! {IO, Process} {
match exec(cmd, args) {
Ok(out) => {
println("stdout: ${toString(out.stdout)}");
println("exit code: ${show(out.exitCode)}")
},
Err(NotFound(cmd)) => println("not found: ${cmd}"),
Err(NotAllowed(cmd)) => println("blocked: ${cmd}"),
Err(Timeout(ms)) => println("timed out after ${show(ms)}ms"),
Err(_) => println("other error")
}
}
Builtins:
| Function | Type | Description |
|---|---|---|
exec | (string, [string]) -> Result[ProcessOutput, ProcessError] ! {Process} | Execute command with arguments |
spawnProcess | (string, [string]) -> ProcessHandle ! {Process} | Spawn subprocess with writable stdin pipe |
spawnProcessResult | (string, [string]) -> Result[ProcessHandle, ProcessError] ! {Process} | Spawn with recoverable authorization/startup errors |
writeProcessStdin | (ProcessHandle, bytes) -> Result[(), string] ! {Process} | Write bytes to subprocess stdin |
closeProcessStdin | (ProcessHandle) -> () ! {Process} | Signal EOF after accepted writes drain; retain worker ownership |
cancelProcess | (ProcessHandle) -> Result[(), WorkerCancelError] ! {Process} | Stop the owned worker and join its runtime tasks |
spawnProcessResult returns Ok(handle) or Err(NotFound(detail)), Err(NotAllowed(detail)) (including confined Process), or Err(SpawnFailed(reason)) for OS/admission failures. Missing Process capability remains a run policy error. A failed admission may start a child before stopping and reaping it; no usable handle is returned. Legacy spawnProcess still aborts on failure.
ProcessHandle: Opaque ADT ProcessHandle(int) — returned by spawnProcess or Ok(handle) from spawnProcessResult, used with writeProcessStdin and closeProcessStdin.
Completion Semantics (important):
Ok= process completed (even with non-zero exit code) — checkout.exitCodeErr= infrastructure failure (command not found, timeout, blocked by allowlist)
Worker lifetime and cancellation: CLI runs, batch items, embedded calls, and REPL sessions own their workers. Returning from the owner stops and joins workers; closing stdin keeps a live worker tracked. Cancellation resolves a handle in the current owner's registry and may discard pending writes. Ok(()) means the direct child has been reaped and owned IO tasks have joined. When a managed or async child invokes run on the same runtime executable (including a symlink to it), cancellation also requires acknowledgement that the child has cleaned up its owned workers. This covers separately grouped policy workers; a missing acknowledgement returns WorkerCancelFailed or WorkerCancelTimedOut. Copies or older/different runtimes retain the ordinary process-group boundary.
WorkerCancelError has WorkerHandleInvalid(string), WorkerCancelUnsupported(string), WorkerCancelTimedOut(int) (milliseconds), and WorkerCancelFailed(string). Released, fabricated, and foreign handles are invalid. An already-exited worker is automatically reaped, so cancelProcess returning WorkerHandleInvalid afterward is the normal reaped case. Cancellation of a stdin or connection source is unsupported. Both Process and Stream capabilities and budgets apply to cancelProcessSource; mandatory host cleanup runs independently of user budgets.
macOS/Linux cancellation stops the worker's process group, including non-detached descendants. Shutdown has one two-second total deadline, with up to 250 ms for workers already draining closed stdin. Windows explicit cancellation returns unsupported; host shutdown attempts direct-child cleanup. JS/WASM returns typed unsupported cancellation. This operation stops a local OS worker; it does not promise remote AI inference or billing cancellation. SIGINT/SIGTERM restore terminal state before worker cleanup and retain exit codes 130/143. Abrupt host SIGKILL and detached descendants are outside this guarantee.
Go hosts supplying an effect context retain ownership of that base context. Engine.SetEffContext creates an engine-owned child, requests have independent child registries, and Engine.Close shuts those children down. Hosts that directly own an EffContext must call CloseWorkers() when their execution ends. REPL handles persist between lines and end on reset or quit. Stream selection borrows sources: returning false leaves them available for another selection or explicit cancellation. Source teardown leaves borrowed connection transports open; arbitrary blocked stdin readers may remain pending, holding their input lease until the read finishes.
ProcessOutput fields: stdout: bytes, stderr: bytes, exitCode: int, truncated: bool, resolvedPath: string
ProcessError variants: NotFound, NotAllowed, PermissionDenied, Timeout, OutputLimitExceeded, SpawnFailed, AbnormalExit
Security Features:
- No shell expansion — arguments passed literally (no
sh -c) - Command allowlist with path pinning (resolved at startup, prevents TOCTOU)
- Mandatory timeout (default: 30s)
- Output size limits (default: 10MB)
CLI Flags:
| Flag | Meaning |
|---|---|
--caps Process | Grant the effect. Without it every exec fails with a capability error. |
--process-allowlist <bins> | Comma-separated binaries the program may exec. Entries match the command string exactly as written: allowlisting name does not authorize /abs/path/name, or vice versa. Each entry is resolved via PATH once, at startup, and pinned to that absolute path — a same-named binary placed earlier in PATH later cannot satisfy it. Anything not listed fails with NotAllowed. This is the flag that turns "the tool has Process, so it is effectively a shell" into "the tool may run these five binaries". Enforced identically by exec, spawnProcess and asyncExecProcess. Allowlisting a shell script does not grant its interpreter. |
--process-allowlist cmd:sub[:sub…] | Subcommand narrowing. git:status,git:commit,gh:pr:list allows git status …, git commit … and gh pr list … and refuses every other invocation of git/gh with NotAllowed("git push") — the refusal names the subcommand that was tried. Rules: a chain must match positionally from the first argument, so git -C /x status is refused under git:status (fail closed; put global options after the subcommand or add a wrapper); arguments after the chain are unrestricted (git:commit allows git commit --amend — narrowing is per subcommand, not per flag); a bare git or git:* in the same list is the broadest grant and wins; an empty segment (git:, git::status) is a startup error, never a wider allow. |
--process-timeout <dur> | Per-exec wall-clock limit (default 30s) → Timeout. |
--process-max-output <bytes> | Combined stdout+stderr cap (default 10MB) → OutputLimitExceeded, truncated: true. |
ailang run --caps Process --entry main module.ail
ailang run --caps IO,Process --process-timeout 10s --entry main module.ail
ailang run --caps IO,Process --process-allowlist "echo,curl,git" --entry main module.ail
ailang run --caps IO,Process --process-allowlist "git:status,git:log,gh:pr:list" --entry main module.ail
ailang run --caps IO,Process --process-max-output 5242880 --entry main module.ail
Runnable example: examples/runnable/process_subcmd_allowlist.ail.
The allowlist is a property of the invocation, not of the program's signature — run the .ail without the flag and it may exec anything. Signature-level narrowing (! {Process[scope=...]}) is tracked under M-EFFECT-REFINEMENT.
Stream Effect
Real-time streaming I/O: WebSocket, SSE, and multi-source event multiplexing.
import std/stream (connect, transmit, onEvent, runEventLoop, disconnect,
sourceOfConn, asyncReadStdinLines, asyncExecProcess, selectEvents,
StreamEvent, Message, Closed, StreamError, SourceText, SourceBytes)
import std/result (Result, Ok, Err)
func main() -> unit ! {Stream, IO} {
let stdin = asyncReadStdinLines("stdin", 10);
selectEvents([stdin], \event. match event {
SourceText(source, text) => { println("[${source}] ${text}"); true },
_ => true
})
}
Core Builtins:
| Function | Type | Description |
|---|---|---|
connect | (string, StreamConfig) -> Result[StreamConn, StreamErrorKind] ! {Stream} | Open WebSocket |
transmit | (StreamConn, string) -> Result[unit, StreamErrorKind] ! {Stream} | Send text message |
transmitBinary | (StreamConn, bytes) -> Result[unit, StreamErrorKind] ! {Stream} | Send binary data |
onEvent | (StreamConn, (StreamEvent) -> bool) -> unit ! {Stream} | Register event handler |
runEventLoop | (StreamConn) -> unit ! {Stream} | Block until handler returns false |
disconnect | (StreamConn) -> unit ! {Stream} | Close connection |
sseConnect | (string, StreamConfig) -> Result[StreamConn, StreamErrorKind] ! {Stream} | Open SSE (GET) |
ssePost | (string, string, StreamConfig) -> Result[StreamConn, StreamErrorKind] ! {Stream} | Open SSE (POST) |
sourceOfConn | (StreamConn, string, int) -> StreamSource ! {Stream} | Wrap connection as event source |
asyncReadStdinLines | (string, int) -> StreamSource ! {Stream} | Stdin line reader source |
asyncExecProcess | (string, [string], string, int, int) -> StreamSource ! {Stream, Process} | Subprocess stdout source |
cancelProcessSource | (StreamSource) -> Result[(), WorkerCancelError] ! {Stream, Process} | Stop and join an owned process source |
selectEvents | ([StreamSource], (StreamEvent) -> bool) -> unit ! {Stream} | Multi-source event loop |
Event types: Message, Binary, Opened, Closed, StreamError, Ping, SSEData, SourceText, SourceBytes
See Streaming & Real-Time I/O Guide for complete documentation, examples, and configuration.
ailang run --caps Stream,IO --entry main module.ail
ailang run --caps Stream,Process,IO --process-allowlist "echo,ffmpeg" --entry main module.ail
Effect Syntax
Declaring Effects
Effects are declared after the return type with !:
-- Single effect
func greet() -> () ! {IO} { ... }
-- Multiple effects
func process() -> Data ! {IO, FS, Net} { ... }
-- Pure function (no effects)
func add(x: int, y: int) -> int { x + y }
-- Explicit pure annotation
pure func factorial(n: int) -> int { ... }
Callback Function Types
In callback types, omission and an explicit empty row have different meanings:
| Type | Contract |
|---|---|
T -> U | Implicit open callback effects, charged at hand-off |
T -> U ! {} | Closed pure callback; effectful values are rejected even in effectful callers |
T -> U ! {IO} | Concrete IO contract; the callee pays IO when invoking it |
T -> U ! {e} | Explicit effect polymorphism shared with the enclosing row |
This applies inside aliases, records, tuples, lists and generic containers.
A helper that only stores a mixed callback record can use explicit empty rows on
pure fields and concrete rows on effectful fields. Direct literal construction
still stores callbacks without performing their effects. See the runnable
explicit_empty_callback_contracts.ail example. Explicit-empty callback contracts
are a breaking soundness correction targeting v0.56.0.
Combining Effects
Effects from called functions are combined:
func helper() -> () ! {IO} {
println("Helper called")
}
func main() -> () ! {IO, FS} {
helper(); -- IO effect flows up
let x = readFile("data.txt"); -- FS effect
println(x) -- IO effect
}
Effect Subsumption
Functions can be called in contexts with additional effects:
func pureCalc(x: int) -> int { x * 2 }
func effectful() -> int ! {IO} {
println("Calculating...");
pureCalc(21) -- Pure function called in effectful context
}
Running with Capabilities
Effects require explicit capability grants at runtime:
# Grant IO capability
ailang run --caps IO --entry main program.ail
# Grant multiple capabilities
ailang run --caps IO,FS,Net --entry main program.ail
# Grant all capabilities (development only!)
ailang run --caps ALL --entry main program.ail
Missing capability error:
Error: Function 'main' requires capability IO but it was not granted.
Use --caps IO to grant this capability.
Capabilities required by stdlib module
Reach for a stdlib module and you inherit its capability requirement — declare it in
your function's effect row and grant it with --caps. Plan your --caps set up front,
especially for capability-constrained (auditable/sandboxed) programs:
| stdlib module | Capability | Notes |
|---|---|---|
std/io | IO | print, println, readLine, writeBytes, flush, printErr/eprintln |
std/fs | FS | file read/write |
std/clock | Clock | now, sleep (use a seed for deterministic time) |
std/datetime | Clock | now() is effectful; formatting/arithmetic are pure |
std/net | Net | HTTP; also needs --net-allow-* flags |
std/rand | Rand | rand_int, rand_float, rand_bool, rand_seed, uuid4 |
std/env | Env | environment variables (with an allowlist) |
std/process | Process | spawn external commands |
std/ai | AI | model calls |
std/list, std/string, std/option, std/result, std/map, std/bytes, … | (none) | pure — usable from pure func, no --caps |
If you must stay within a fixed capability set (e.g. --caps IO,Clock) and a module needs
more, reach for a pure alternative: e.g. a hand-rolled PRNG instead of std/rand (which
requires Rand).
Capability Budgets
You can limit how many times a function can perform an effect using capability budgets:
-- Function limited to 5 IO operations (maximum)
func rateLimited() -> () ! {IO @limit=5} {
println("Call 1"); -- Uses 1/5 budget
println("Call 2"); -- Uses 2/5 budget
-- ...
println("Call 6"); -- FAILS: BudgetExhaustedError
()
}
-- Function must perform at least 1 IO operation (minimum)
func auditRequired() -> () ! {IO @min=1} {
println("Audit log entry"); -- Satisfies @min=1
()
-- Returning without any IO would FAIL: BudgetUnderrunError
}
-- Combined: between 1 and 3 IO operations
func bounded() -> () ! {IO @min=1 @limit=3} {
println("Required"); -- Satisfies minimum
println("Optional"); -- Within limit
()
}
Hierarchical scoping semantics
Budgets are per-invocation and hierarchical, enforced by a stack of budget frames:
- Per-invocation frames. Each call to an annotated function pushes its own
frame recording that invocation's
usedcount.@limit=3means this function's own invocation may perform at most 3 matching effects — the caller's earlier effects are not charged against it. (Recursion pushes an independent frame per call.) - Unannotated functions push no frame. Their effects charge whatever annotated ancestor frames are currently active, so budgets compose through unannotated intermediates — an unannotated helper called from an annotated function still spends the caller's budget.
- Bubbling charge. Every matching effect op charges the current (innermost) frame and all active ancestor frames that constrain that effect. A callee's effects therefore count toward an annotated caller's budget (delegation cannot launder effects), while an annotated callee is also independently bounded by its own frame.
@limit=N— checked pre-op against every active frame. Before an effect op, every active frame is inspected without being mutated; a frame violates whenused + 1 > limit. If one or more frames would be exceeded, the op is rejected (nothing is incremented, the op does not run) and the error names the first violating frame innermost-to-outermost (its function, its limit, and its own used count) — the tightest active limit wins. Otherwise every matching active frame is incremented and the op proceeds.@min=N— checked at frame pop on normal exit only. When an annotated invocation returns normally, its frame's own bubbled count must be at least N (elseBudgetUnderrunError). On an error/exceptional exit (a callee raised, a@limittripped, any unwind) the frame is still popped but the@mincheck is suppressed — the original error propagates unmasked.
Key features:
- Per-invocation frames: each call to an annotated function is bounded on its own
- Composable / bubbling: nested effects charge every active ancestor frame
- Maximum limits (
@limit=N): checked pre-op, tightest active frame wins - Minimum requirements (
@min=N): checked at normal-exit pop, suppressed on error - Bypass for debugging: Use
--no-budgetsflag
# Normal mode - budgets enforced
ailang run --caps IO --entry main program.ail
# Debug mode - budgets bypassed
ailang run --caps IO --no-budgets --entry main program.ail
See Capability Budgets for the complete reference.
Effect Safety
Static Checking
The type checker verifies effect declarations:
-- Error: readFile requires FS but not declared
func badFunction() -> string ! {IO} {
readFile("data.txt") -- Compile error!
}
Runtime Enforcement
Even if you declare an effect, you must grant it:
func main() -> () ! {FS} {
writeFile("output.txt", "data")
}
# Fails - FS not granted
ailang run --caps IO --entry main program.ail
# Error: Capability FS required but not granted
Effect Patterns
Effect Polymorphism
Functions can work with any effect set:
-- Works with any effects
func twice[e](f: () -> () ! {e}) -> () ! {e} {
f();
f()
}
-- Usage
twice(\(). println("Hello")) -- ! {IO}
twice(\(). writeFile("log", "entry")) -- ! {FS}
The callback row and the call row share the same effect variable. A pure callback resolves the call to pure; an IO callback requires IO at the caller. A helper that invokes an explicitly effect-polymorphic callback must propagate that row, including when it invokes the callback twice. An unresolved tail is reported directly rather than producing an empty suggested annotation. Runtime capability grants are still required for concrete effects.
Conditional Effects
Use pattern matching to handle effect boundaries:
func maybeLog(verbose: bool, msg: string) -> () ! {IO} {
if verbose then println(msg) else ()
}
Effect-Free Core
Keep business logic pure, effects at boundaries:
-- Pure business logic
pure func calculateTax(amount: float) -> float {
amount * 0.2
}
-- Effectful boundary
func processInvoice(id: string) -> () ! {IO, FS} {
let data = readFile("invoices/${id}.json");
let amount = parseAmount(data);
let tax = calculateTax(amount); -- Pure!
println("Tax: ${show(tax)}")
}
Testing with Effects
Mocking Effects (Planned)
-- Future: Mock effects for testing
test "file processing" with {
FS.mock({
"input.txt" => "test data"
})
} {
let result = processFile("input.txt");
assert(result == "TEST DATA")
}
Pure Function Testing (Current)
Use inline tests for pure functions:
pure func double(x: int) -> int
tests [
(0, 0),
(5, 10),
((-3), (-6))
]
{
x * 2
}
Related Resources
- AI Effect Guide - Using the AI effect for LLM calls
- Streaming Guide - WebSocket, SSE, and multi-source multiplexing
- Capability Budgets - Fine-grained effect limiting
- Module Execution - Running modules with effects
- Language Syntax - Complete syntax reference
- Module System - Imports and exports
- Testing Guide - Writing tests
- Debugging Guide - Troubleshooting effect-related issues
- Limitations - Known limitations