- SKILLS.md: Update roadmap phases with actual completion status - Phase 0-1 complete, Phase 2-5 partial, resolved design decisions - OVERVIEW.md: Add HttpServer, Test effect, JIT to completed features - ROADMAP.md: Add HttpServer, Process, Test effects to done list - VISION.md: Update Phase 2-3 tables with current status - guide/05-effects.md: Add Time, HttpServer, Test to effects table - guide/09-stdlib.md: Add HttpServer, Time, Test effect docs - reference/syntax.md: Fix interpolation syntax, remove unsupported literals - testing.md: Add native Test effect documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
7.6 KiB
Chapter 5: Effects
This is where Lux gets interesting. Effects are the core innovation—they make side effects explicit, controllable, and composable.
The Problem with Side Effects
In most languages, functions can do anything:
// JavaScript - what does this do?
function process(x) {
return x * 2;
}
It looks pure, but it could:
- Print to console
- Write to a file
- Make HTTP requests
- Throw exceptions
- Modify global state
- Launch missiles
You can't tell from the signature. You have to read the implementation.
The Lux Solution
In Lux, effects are declared:
// Pure - only computes
fn double(x: Int): Int = x * 2
// Uses Console - you can see it
fn printDouble(x: Int): Unit with {Console} =
Console.print(toString(x * 2))
The with {Console} tells you this function interacts with the console. It's part of the type.
Built-in Effects
Lux provides several built-in effects:
| Effect | Operations | Purpose |
|---|---|---|
Console |
print, readLine, readInt |
Terminal I/O |
Fail |
fail |
Early termination |
State |
get, put |
Mutable state |
Random |
int, float, bool |
Random numbers |
Time |
now, sleep |
Time operations |
File |
read, write, exists, delete, list, mkdir |
File system |
Process |
exec, env, args, cwd, exit |
System processes |
Http |
get, post, put, delete |
HTTP client |
HttpServer |
listen, accept, respond, stop |
HTTP server |
Test |
assert, assertEqual, assertTrue, assertFalse |
Testing |
Example usage:
fn main(): Unit with {Console, Random} = {
let n = Random.int(1, 100)
Console.print("Random number: " + toString(n))
}
let output = run main() with {}
Effect Propagation
Effects propagate up the call stack:
fn helper(): Int with {Console} = {
Console.print("In helper")
42
}
// Must declare Console because it calls helper
fn caller(): Int with {Console} = {
let x = helper()
x * 2
}
// Error: caller uses Console but doesn't declare it
fn broken(): Int = {
caller() // Error!
}
The rule: if you call a function with effect E, you must either:
- Declare E in your signature
- Handle E with a
run ... with {}block
Running Effects
The run ... with {} block executes effectful code:
fn greet(): Unit with {Console} =
Console.print("Hello!")
// Execute with default handlers
let result = run greet() with {}
For built-in effects, with {} uses the default implementations (real console, real files, etc.).
Custom Effects
You can define your own effects:
effect Logger {
fn log(level: String, message: String): Unit
fn getLevel(): String
}
This declares an effect with two operations. To use it:
fn processData(data: Int): Int with {Logger} = {
Logger.log("info", "Starting processing")
let result = data * 2
Logger.log("debug", "Result: " + toString(result))
result
}
But this won't run yet—we need a handler.
Handlers
Handlers define how effect operations behave:
handler consoleLogger: Logger {
fn log(level, message) = {
Console.print("[" + level + "] " + message)
resume(())
}
fn getLevel() = resume("debug")
}
Key concept: resume(value) continues the computation with value as the result of the effect operation.
Now we can run:
fn main(): Unit with {Console} = {
let result = run processData(21) with {
Logger = consoleLogger
}
Console.print("Final: " + toString(result))
}
let output = run main() with {}
Output:
[info] Starting processing
[debug] Result: 42
Final: 42
The Power of Handlers
Different Implementations
Same code, different behaviors:
// Console logging
handler consoleLogger: Logger {
fn log(level, msg) = {
Console.print("[" + level + "] " + msg)
resume(())
}
fn getLevel() = resume("debug")
}
// Silent (for testing)
handler silentLogger: Logger {
fn log(level, msg) = resume(())
fn getLevel() = resume("none")
}
// Collecting logs
handler collectingLogger: Logger {
fn log(level, msg) = {
State.put(State.get() + "[" + level + "] " + msg + "\n")
resume(())
}
fn getLevel() = resume("all")
}
Resumable Operations
Unlike exceptions, handlers can continue the computation:
effect Ask {
fn ask(prompt: String): String
}
fn survey(): String with {Ask} = {
let name = Ask.ask("Name?")
let age = Ask.ask("Age?")
name + " is " + age + " years old"
}
// Handler that provides answers
handler mockAnswers: Ask {
fn ask(prompt) =
if String.contains(prompt, "Name") then resume("Alice")
else resume("30")
}
run survey() with { Ask = mockAnswers }
// Returns: "Alice is 30 years old"
The computation pauses at Ask.ask, the handler provides a value, and resume continues from where it left off.
Effect Composition
Multiple effects combine naturally:
fn program(): Int with {Console, Random, Logger} = {
Logger.log("info", "Starting")
let n = Random.int(1, 10)
Console.print("Got: " + toString(n))
Logger.log("debug", "Returning " + toString(n))
n
}
let result = run program() with {
Logger = consoleLogger
}
No monad transformers, no lifting, no complexity. Effects just work together.
Why This Matters
1. Testability
// Production code
fn fetchUser(id: Int): String with {Http} =
Http.get("https://api.example.com/users/" + toString(id))
// Test with mock HTTP
handler mockHttp: Http {
fn get(url) = resume("{\"name\": \"Test User\"}")
// ... other operations
}
// Test runs without network
let result = run fetchUser(1) with { Http = mockHttp }
2. Explicit Dependencies
// You can see exactly what this function needs
fn processOrder(order: Order): Receipt with {Database, Logger, Email}
3. Controlled Side Effects
// This function is pure - it CAN'T do I/O
fn calculateTotal(items: List<Item>): Int =
List.fold(items, 0, fn(acc: Int, item: Item): Int => acc + item.price)
// Compile error if you try to add Console.print here
Common Patterns
Dependency Injection
effect Database {
fn query(sql: String): List<Row>
fn execute(sql: String): Int
}
fn getUsers(): List<User> with {Database} =
Database.query("SELECT * FROM users")
// Production
handler postgresDb: Database { /* real implementation */ }
// Testing
handler mockDb: Database {
fn query(sql) = resume([mockRow1, mockRow2])
fn execute(sql) = resume(1)
}
Configuration
effect Config {
fn get(key: String): String
}
fn appUrl(): String with {Config} =
Config.get("APP_URL")
handler envConfig: Config {
fn get(key) = resume(Process.env(key))
}
handler testConfig: Config {
fn get(key) = resume("http://localhost:8080")
}
Early Return
fn validateUser(user: User): User with {Fail} = {
if user.name == "" then Fail.fail("Name required")
else if user.age < 0 then Fail.fail("Invalid age")
else user
}
// Fail stops execution
let result = run validateUser(invalidUser) with {}
Summary
| Concept | Syntax |
|---|---|
| Declare effect | effect Name { fn op(): Type } |
| Use effect | fn f(): T with {Effect} |
| Effect operation | Effect.operation(args) |
| Define handler | handler name: Effect { fn op(...) = ... } |
| Resume | resume(value) |
| Run with handler | run expr with { Effect = handler } |
Next
Chapter 6: Handlers - Deep dive into handler patterns.