Algebraic data types (ADTs) are Elara’s primary mechanism for defining custom types. They come in two forms: sum types (discriminated unions) and product types (records).
Sum types
Sum types represent a choice between multiple alternatives, each with its own constructor:
type Animal = Cat | Dog
type Bool = True | False
type Ordering = LT | EQ | GT
Constructors with parameters
Constructors can carry data:
type Option a
= Some a
| None
type RequestState
= Connected String
| Pending
| Failed Error
From the examples:
type Shape =
Circle Int
| Rectangle Int Int
| Square Int
Each constructor can have a different number and type of parameters, providing maximum flexibility.
Using sum types
Sum types are used with pattern matching:
def area : Shape -> Int
let area shape =
match shape with
Circle r -> 3 * r * r
Rectangle w h -> w * h
Square s -> s * s
let main =
let c = Circle 5
let r = Rectangle 4 6
let s = Square 3
println ("Area of Circle: " ++ toString (area c))
Product types (records)
Records are first-class types with named fields:
-- Record type
type alias Person = {
name : String,
age : Int
}
-- Creating a record
def person : Person
let person = { name = "Bob", age = 20 }
Records use : in type definitions and = when creating values.
Anonymous records
Records don’t require type aliases:
def point : { x: Int, y: Int }
let point = { x = 10, y = 20 }
def coordinates : { latitude: Float, longitude: Float }
let coordinates = { latitude = 40.7128, longitude = -74.0060 }
Type aliases
Create named types for existing types:
type alias Name = String
type alias Age = Int
type alias Pair a b = (a, b)
From the examples:
type Number = Int
def assertNumber : Int -> Number
let assertNumber n = n
type Identity s = s
def makeIdentity : a -> Identity a
let makeIdentity x = x
Recursive aliases
Type aliases can be recursive:
type Person = { name: String, children: [Person] }
def child : Person
let child = { name = "Bob", children = [] }
def parent : Person
let parent = { name = "Alice", children = [child] }
Recursive aliases are not simple synonyms—they create distinct recursive types.
Combining sum and product types
The real power comes from combining both:
Option type
type Option a = Some a | None
def safeDiv : Int -> Int -> Option Int
let safeDiv x y =
if y == 0 then
None
else
Some (x / y)
Result type
type Result e a
= Ok a
| Err e
def parseInt : String -> Result String Int
let parseInt str = ...
Complex nested structures
From the specification:
type JSONElement
= JSONString String
| JSONNumber Int
| JSONNull
| JSONArray [JSONElement]
| JSONObject [{
key : String,
value : JSONElement
}]
def jsonObj : JSONElement
let jsonObj =
JSONObject
[ { key = "foo", value = JSONNumber 1 }
, { key = "bar", value = JSONObject [
{ key = "baz", value = JSONString "hello" }
]}
]
Recursive data types
ADTs can reference themselves:
type List a = Nil | Cons a (List a)
type Tree a
= Leaf a
| Branch (Tree a) (Tree a)
Recursive types are essential for defining data structures like lists, trees, and graphs.
Higher-kinded types
Types can take type constructors as parameters:
type Fix f = Fix (f (Fix f))
This defines the fixed point of a functor, useful for recursive data structures.
Parameterized types
Types can have multiple type parameters:
type Either a b
= Left a
| Right b
type Pair a b = { first: a, second: b }
type Map k v = ...
From the standard library:
type Tuple2 a b = Tuple2 a b
def zip : List a -> List b -> List (Tuple2 a b)
let zip l1 l2 =
match (l1, l2) with
(Nil, _) -> Nil
(_, Nil) -> Nil
(Cons x xs, Cons y ys) -> Cons (x, y) (zip xs ys)
Pattern matching on ADTs
ADTs are consumed through pattern matching:
def isSome : Option a -> Bool
let isSome opt =
match opt with
Some _ -> True
None -> False
def map : (a -> b) -> Option a -> Option b
let map f opt =
match opt with
None -> None
Some x -> Some (f x)
Nested pattern matching
def describe : Option (List Int) -> String
let describe opt =
match opt with
None -> "nothing"
Some [] -> "empty list"
Some (x :: xs) -> "list starting with " ++ toString x
Single-constructor optimization
Types with a single constructor can be optimized:
-- This type
type Box a = Box a
-- Can be treated as
type Box a = a
The compiler eliminates the constructor wrapper for performance.
Single-constructor ADTs are useful for creating distinct types with custom type class instances without runtime overhead.
Practical examples
Modeling state machines
type ConnectionState
= Disconnected
| Connecting { host: String, port: Int }
| Connected { socket: Socket }
| Failed { error: String }
def handleState : ConnectionState -> IO ()
let handleState state =
match state with
Disconnected -> println "Not connected"
Connecting { host, port } ->
println ("Connecting to " ++ host ++ ":" ++ toString port)
Connected _ -> println "Connected"
Failed { error } -> println ("Error: " ++ error)
Modeling trees
type Tree a
= Empty
| Node { value: a, left: Tree a, right: Tree a }
def insert : Int -> Tree Int -> Tree Int
let insert x tree =
match tree with
Empty -> Node { value = x, left = Empty, right = Empty }
Node { value, left, right } ->
if x < value then
Node { value = value, left = insert x left, right = right }
else
Node { value = value, left = left, right = insert x right }
Modeling expressions
type Expr
= Literal Int
| Add Expr Expr
| Multiply Expr Expr
| Variable String
def eval : Expr -> Int
let eval expr =
match expr with
Literal n -> n
Add e1 e2 -> eval e1 + eval e2
Multiply e1 e2 -> eval e1 * eval e2
Variable _ -> error "Cannot evaluate variable"
Type safety benefits
ADTs provide compile-time guarantees:
- Exhaustiveness checking: The compiler ensures all cases are handled
- Type safety: Invalid combinations are caught at compile time
- Refactoring safety: Adding new constructors reveals all places that need updating
- Self-documenting: Types clearly express what data is possible
-- Compiler ensures all cases handled
def handle : Option Int -> String
let handle opt =
match opt with
Some x -> toString x
None -> "nothing"
-- Forgetting a case causes a compile error