SagaSaga
Guide

Types

Saga has two main ways to define types: Algebraic Data Types (ADTs) for variants, and records for structured data. Both can be generic, and they compose naturally together.

Algebraic Data Types

An ADT defines a type as one of several variants. Each variant is either bare (no data) or carries one or more values:

type Direction = North | South | East | West

type Shape =
  | Circle Float
  | Rect Float Float
  | Point

Variant arguments can have optional labels for documentation. Labels have no effect on construction or pattern matching, they just make the definition clearer:

type Shape =
  | Circle (radius: Float)
  | Rect (width: Float) (height: Float)
  | Point

Constructing a value uses the variant name followed by its arguments:

let c = Circle 5.0
let r = Rect 3.0 4.0

Records

Records are named collections of fields:

record Point {
  x: Int,
  y: Int,
}

let p = Point { x: 3, y: 4 }
p.x   # 3

Since records have no mutable state, "updating" a record means creating a new one with some fields changed:

let p2 = { p | x: 10 }   # new Point, x changed, y unchanged

Anonymous record fields

A record field can itself be an inline record type, without needing to define a separate named record:

record User {
  id: Int,
  name: { first: String, last: String },
  email: String,
}

let u = User {
  id: 1,
  name: { first: "Alice", last: "Smith" },
  email: "alice@example.com",
}

Dot access chains through the levels:

u.name.first   # "Alice"

Record update works at any level too:

let u2 = { u | name: { u.name | last: "Jones" } }

ADTs with Record Variants

A useful pattern is using named records as ADT variants. Instead of putting field definitions inside the ADT, define separate records and reference them:

record Success {
  status: Int,
  body: String,
}

record ApiError {
  code: Int,
  message: String,
}

type ApiResponse =
  | Success
  | ApiError

Pattern matching then uses record syntax:

describe resp = case resp {
  Success { status, body } -> $"OK {show status}"
  ApiError { code, message } -> $"Error {show code}: {message}"
}

This keeps your data shapes reusable and lets you pattern match on fields by name rather than position.

Tuples

Tuples group a fixed number of values without defining a record. Any arity works, no predefinition needed:

let point = (3.0, 4.0)
let tagged = ("alice", 42)    # (String, Int) -- different types are fine

fun swap : (a, b) -> (b, a)
swap (x, y) = (y, x)    # tuple arguments can be destructured!

Tuples are useful for returning multiple values from a function, or for quick grouping where a named record would be overkill.

Generic Types

Types can take type parameters, written as lowercase letters after the type name:

type Maybe a =
  | Just a
  | Nothing

type Result a e =
  | Ok a
  | Err e

The parameter a stands in for any type. When you use Just 42, the compiler infers Maybe Int. When you use Nothing, the type is inferred from context.

Records can be generic too:

record Box a {
  value: a,
}

let b1 = Box { value: 42 }       # Box Int
let b2 = Box { value: "hello" }  # Box String

Functions that never return

Some functions can never actually return a value: panic crashes the program, fail! transfers control to a handler. These are typed as -> a, a free type variable that unifies with any expected type. This lets them appear in any expression position without a type mismatch:

# Both branches must return the same type.
# panic is typed -> a, so it unifies with Int here.
safe_head xs = case xs {
  h :: _ -> h
  []     -> panic "empty list"
}

You will see this in signatures like fun fail : String -> a and fun panic : String -> a. It is not a special type -- it is just an unconstrained type variable, which means the type checker is happy to accept it wherever any concrete type is expected.

Built-in Types

Saga's prelude provides several common generic types:

TypeDescription
Maybe aAn optional value: Just a or Nothing
Result a eSuccess or failure: Ok a or Err e
List aA linked list, with [] and :: syntax
Dict k vAn immutable key-value map

Lists have special literal syntax:

let nums = [1, 2, 3]
let nested = [[1, 2], [3, 4]]
let prepend = 0 :: nums   # [0, 1, 2, 3]

These types are covered in depth in the standard library reference.

Type Aliases

A type alias gives an existing type a new name. Aliases are structural - the compiler unfolds them before any type comparison, so UserId and Int are fully interchangeable in the example below:

type alias UserId = Int

fun next_id : UserId -> UserId
next_id n = n + 1

let uid : UserId = 42
let n : Int = next_id uid    # OK — UserId is just Int

Aliases can take parameters of their own, and they can wrap any type expression - including parameterized types and function types:

type alias Bag a = List a
type alias Decoded a = Result a String
type alias Predicate a = a -> Bool

Pattern matching, constructor application, and trait resolution all see through aliases to the underlying type:

fun handle : Decoded Int -> Int
handle r = case r {
  Ok n  -> n
  Err _ -> 0
}

pub type alias exports the alias from a module like any other type.

Restrictions

A few rules keep aliases predictable rather than half-clever:

  • Aliases must be fully applied. Writing Bag (a 1-arity alias) without an argument is rejected — the alias must always be saturated at every use site.
  • Aliases cannot be recursive. type alias T = List T is a cycle and is rejected at the alias declaration.
  • All variables in the body must be declared in the alias's parameter list. type alias Foo = Maybe b is rejected because b isn't a parameter - Saga doesn't implicitly quantify alias bodies.

If you need a nominally-distinct type rather than structural aliasing, use a single-constructor ADT (type UserId = UserId Int) instead - or pair one with a symbol-tagged parameter to get many distinct roles backed by a single wrapper.