Files
lux/docs/guide/05-effects.md
Brandon Lucas 8c7354131e docs: update documentation to match current implementation
- 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>
2026-02-14 02:56:42 -05:00

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:

  1. Declare E in your signature
  2. 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.