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 -> IntTypes 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 silentError 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 = nimport Math
Math.abs (-5) # 5Testing
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_actorWhy 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_resultThree 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.