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
| PointVariant 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)
| PointConstructing a value uses the variant name followed by its arguments:
let c = Circle 5.0
let r = Rect 3.0 4.0Records
Records are named collections of fields:
record Point {
x: Int,
y: Int,
}
let p = Point { x: 3, y: 4 }
p.x # 3Since 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 unchangedAnonymous 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
| ApiErrorPattern 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 eThe 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 StringFunctions 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:
| Type | Description |
|---|---|
Maybe a | An optional value: Just a or Nothing |
Result a e | Success or failure: Ok a or Err e |
List a | A linked list, with [] and :: syntax |
Dict k v | An 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 IntAliases 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 -> BoolPattern 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 Tis 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 bis rejected becausebisn'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.