SagaSaga
Guide

Language Tour

Saga is a statically typed functional language that compiles to the BEAM. It has full type inference, pattern matching, and an effect system that gives you dependency injection, error handling, and testability as language features rather than library conventions.

Functions and Types

Functions are defined with optional type annotations. The compiler infers types when you leave them off:

pub fun add : Int -> Int -> Int
add x y = x + y

double x = x * 2   # inferred as Int -> Int

Types are algebraic data types and records:

type Shape =
  | Circle Float
  | Rect Float Float

record User {
  name: String,
  age: Int,
}

Pattern Matching

case expressions are exhaustive. The compiler ensures you handle every possibility:

area shape = case shape {
  Circle r   -> 3.14159 * r * r
  Rect w h   -> w * h
}

Patterns also work in function definitions:

fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)

Pipes

The pipe operator threads values through a chain of functions, left to right:

[1, 2, 3, 4, 5]
|> List.filter (fun x -> x % 2 == 0)
|> List.map (fun x -> x * 10)

Traits

Traits define shared behavior across types, similar to interfaces:

trait Show a {
  fun show : a -> String
}

impl Show for User {
  show user = $"{user.name} (age {user.age})"
}

The compiler can derive common traits automatically:

record Point { x: Int, y: Int } deriving (Show, Eq, Ord)

Effects and Handlers

Effects are how Saga handles operations like I/O, errors, and database access. Think of them as swappable interfaces whose implementation is chosen by the caller:

effect Log {
  fun log : String -> Unit
}

fun process : String -> Unit needs {Log}
process input = log! $"processing: {input}"

A handler provides the implementation. The same code can run with different handlers:

handler console for Log needs {Stdio} {
  log msg = {
    print! msg
    resume ()
  }
}

handler silent for Log {
  log _ = resume ()
}

# Production
process "data" with console

# Tests
process "data" with silent

Error Handling

No exceptions. The Fail effect handles recoverable errors, and handlers decide what happens:

fun parse_age : String -> Int needs {Fail}
parse_age s = case String.to_int s {
  Ok n  -> n
  Err _ -> fail! "not a number"
}

# Convert to Result
parse_age "25" with to_result   # Ok 25
parse_age "abc" with to_result  # Err "not a number"

Modules

Files declare a module name and control visibility with pub:

module Math

pub fun abs : Int -> Int
abs n when n < 0 = -n
abs n = n
import Math
Math.abs (-5)   # 5

Testing

Tests are ordinary modules that export a tests function. Handler swapping gives you isolation with no mocking libraries:

import Std.Test (Testing, test, assert_eq)

pub fun tests : Unit -> Unit needs {Testing}
tests () = {
  test "get_users returns results" (fun () -> {
    let result = {
      get_users ()
    } with {
      query _ = resume ["alice", "bob"],
    }
    assert_eq result ["alice", "bob"]
  })
}

Concurrency

Saga runs on the BEAM, so you get lightweight processes and message passing:

import Std.Actor (Process, Actor, beam_actor)

fun greeter : Unit -> Unit needs {Actor Msg}
greeter () = receive {
  Hello name -> { dbg $"Hello, {name}!"; greeter () }
  Goodbye -> dbg "Bye!"
}

main () = {
  let pid = spawn! (fun () -> greeter ())
  send! pid (Hello "Alice")
  send! pid Goodbye
} with beam_actor

Why Does This Matter?

Features are nice, but what do they buy you in practice?

Testing without mocks

A function that queries a database is testable with no mock library, no dependency injection container, and no patching imports. Just provide a different handler:

pub fun get_users : Unit -> List String needs {Database}
get_users () = query! "SELECT name FROM users"

test "returns users" (fun () -> {
  let result = {
    get_users ()
  } with {
    query _ = resume ["alice", "bob"],
  }
  assert_eq result ["alice", "bob"]
})

The function under test is unchanged. The test provides an inline handler that returns canned data. That's the entire setup.

Swapping implementations

Dev uses SQLite, production uses Postgres. Same application code, one choice at the entry point:

main () = {
  let db = if env == "dev" then sqlite else postgres
  run_app ()
} with { db, console }

No interface hierarchies, no factory patterns, no configuration frameworks.

Errors that propagate cleanly

fail! propagates through any number of function calls without manual forwarding. One handler at the boundary catches everything:

fun parse : String -> Config needs {Fail}
fun validate : Config -> Config needs {Fail}
fun load : Config -> App needs {Fail}

fun start : String -> Result App String
start input = {
  input |> parse |> validate |> load
} with to_result

Three functions that can each fail. No nested case expressions, no error codes, no try/catch. If any step calls fail!, the handler at the top produces an Err.

Next Steps

The rest of the guide covers each feature in depth, starting with Basics.