Skip to main content

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.

Academic Foundation

AILANG's effect system builds on foundational research:

See Citations & Bibliography for complete references.

See Effects in Action

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:

FunctionTypeDescription
printlnstring -> () ! {IO}Print with newline
printstring -> () ! {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
exitint -> () ! {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)
}
}
}
Surfaceexit(0)exit(n≠0)
ailang runprocess exits 0process exits n (OS applies & 0xFF)
ailang run --batchthat item succeedsthat item fails with program called exit(n); remaining items still run (v0.33.1, #607)
serve-api route / A2A / MCP handlersuccess (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:

FunctionTypeDescription
readFilestring -> string ! {FS}Read file contents
writeFile(string, string) -> () ! {FS}Write content to file
fileExistsstring -> bool ! {FS}Check if file exists
listDirstring -> [string] ! {FS}List directory entries (sorted)
mkdirstring -> () ! {FS}Create a single directory
mkdirAllstring -> () ! {FS}Create directory tree (mkdir -p)
isDirstring -> bool ! {FS}Check if path is a directory
isFilestring -> bool ! {FS}Check if path is a regular file
removeFilestring -> () ! {FS}Remove file or empty directory
_zip_listEntriesstring -> 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.

Use Cases
  • 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:

FunctionTypeDescription
now() -> int ! {Clock}Current monotonic time (ms)
sleepint -> () ! {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 full HttpResponse record, not just the body. Use resp.body to get the response body string. To parse JSON, do decode(resp.body) not decode(resp).

Builtins:

FunctionTypeDescription
httpGetstring -> 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:

FlagMeaning
--net-allow-domains <list>Domain allowlist (comma-separated).
--net-allow-httpPermit http:// URLs (default: https only).
--net-allow-localhostPermit 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-metadataPermit 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:

FunctionTypeDescription
getEnvstring -> 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:

FunctionTypeDescription
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) — check out.exitCode
  • Err = 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:

FlagMeaning
--caps ProcessGrant 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:

FunctionTypeDescription
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:

TypeContract
T -> UImplicit 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 moduleCapabilityNotes
std/ioIOprint, println, readLine, writeBytes, flush, printErr/eprintln
std/fsFSfile read/write
std/clockClocknow, sleep (use a seed for deterministic time)
std/datetimeClocknow() is effectful; formatting/arithmetic are pure
std/netNetHTTP; also needs --net-allow-* flags
std/randRandrand_int, rand_float, rand_bool, rand_seed, uuid4
std/envEnvenvironment variables (with an allowlist)
std/processProcessspawn external commands
std/aiAImodel 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 used count. @limit=3 means 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 when used + 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 (else BudgetUnderrunError). On an error/exceptional exit (a callee raised, a @limit tripped, any unwind) the frame is still popped but the @min check 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-budgets flag
# 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
}