Pattern Matching
Pattern matching is the heart of Nostos. It's not just a feature - it's a way of thinking about data. Instead of asking "what type is this?" and then digging inside, you describe the shape you expect and the values fall into place.
The "Aha!" Moment
Here's a database query. See how we name the columns right in the pattern:
# Query returns rows as tuples
rows = Pg.query(conn, "SELECT id, name, email FROM users", [])
# Pattern matching gives each column a name - no row[0], row[1] needed!
rows.each((id, name, email) => {
println(name ++ " <" ++ email ++ ">")
})
No index lookups. No parsing. The structure of your data becomes the structure of your code.
Even better - match on the entire row, including country:
# Pattern match on the whole tuple, branching on country
rows = Pg.query(conn, "SELECT id, amount, country FROM orders", [])
rows.each(row => match row {
(id, amount, "NO") -> println("Order #" ++ show(id) ++ ": " ++ show(amount * 1.25) ++ " (incl. 25% Norwegian VAT)")
(id, amount, _) -> println("Order #" ++ show(id) ++ ": " ++ show(amount))
})
The country is matched as part of the tuple pattern - Norwegian orders get special handling, others fall through to the default branch. Note that the match now uses brackets { } instead of end.
Why Pattern Matching?
Traditional languages make you ask about your data: Is it null? What type? How long? Pattern matching lets you declare what shape you expect, and the language handles the rest.
Traditional approach (verbose)
# Pseudo-code for other languages
def process(data):
if data is None:
return "empty"
elif isinstance(data, list):
if len(data) == 0:
return "empty list"
else:
first = data[0]
return f"first: {first}"
else:
return "unknown"
Pattern matching (elegant)
# Nostos pattern matching
process(None) = "empty"
process([]) = "empty list"
process([first | _]) = "first: " ++ show(first)
process(_) = "unknown"
Key Benefits
- Exhaustiveness checking: The compiler warns if you forget a case
- Destructuring built-in: Extract values while matching structure
- No null pointer exceptions: Handle None/Some explicitly
- Self-documenting: The pattern shows exactly what data shape is expected
- Composable: Combine patterns with guards for complex logic
Real-World Example: Parsing Commands
Pattern matching shines when handling structured data. Here's how you might parse user commands:
# Command type with different variants
type Command =
| Help
| Quit
| Load(String)
| Save(String)
| Search(String, Int)
# Handle each command - the compiler ensures all cases are covered
execute(Help) = println("Available commands: help, quit, load, save, search")
execute(Quit) = println("Goodbye!")
execute(Load(filename)) = println("Loading " ++ filename ++ "...")
execute(Save(filename)) = println("Saving to " ++ filename ++ "...")
execute(Search(query, limit)) = println("Searching for '" ++ query ++ "' (max " ++ show(limit) ++ " results)")
main() = {
execute(Help)
execute(Load("data.json"))
execute(Search("nostos", 10))
execute(Quit)
}
Each function clause handles one command variant. If you add a new command to the type, the compiler will remind you to handle it everywhere. No more forgotten cases!
Patterns in Function Definitions
As seen in the Functions chapter, you can define multiple function clauses, each with a pattern. The first matching pattern is executed.
# Literal patterns
is_zero(0) = true
is_zero(_) = false
# List patterns (head and tail)
first_element([x | _]) = x
rest_elements([_ | xs]) = xs
# Tuple patterns
swap((a, b)) = (b, a)
main() = {
println(is_zero(0)) # true
println(is_zero(5)) # false
println(first_element([1, 2, 3])) # 1
println(swap(("hello", 123))) # (123, "hello")
}
The match Expression
For more complex pattern matching or when matching against a single value, the match expression is invaluable.
describe(n) = match n {
0 -> "zero"
1 -> "one"
_ -> "many" # Wildcard pattern for any other value
}
main() = {
println(describe(0)) # "zero"
println(describe(1)) # "one"
println(describe(5)) # "many"
}
Patterns with Guards
Guards allow you to add conditional logic to patterns, making them even more powerful.
classify(n) = match n {
x when x > 0 -> "positive"
0 -> "zero"
_ -> "negative"
}
main() = {
println(classify(10)) # "positive"
println(classify(0)) # "zero"
println(classify(-3)) # "negative"
}
Guard Fallthrough
When a pattern matches but its guard fails, execution falls through to try the next arm. This is important when you have multiple arms with guards on the same pattern.
size_category(n) = match n {
x when x > 100 -> "huge"
x when x > 10 -> "big"
x when x > 0 -> "small"
_ -> "zero or negative"
}
main() = {
println(size_category(50)) # "big" - first guard fails, second matches
println(size_category(5)) # "small" - first two guards fail
println(size_category(-1)) # "zero or negative"
}
The Pin Operator (^)
The pin operator ^ in a pattern asserts that a value must match an existing variable's value.
Without the pin, a variable in a pattern creates a new binding; with the pin, it constrains the match.
expected_status = 200
handle_response(response) = match response {
{ status: ^expected_status, body: data } -> "Success: " ++ data
{ status: s, body: data } -> "Error " ++ show(s) ++ ": " ++ data
}
main() = {
response1 = { status: 200, body: "Data received" }
response2 = { status: 404, body: "Not Found" }
println(handle_response(response1)) # "Success: Data received"
println(handle_response(response2)) # "Error 404: Not Found"
}
The pin operator works in all pattern contexts:
main() = {
x = 10
y = 20
# In tuple patterns
result = match (10, 20) {
(^x, ^y) -> "both match"
(^x, _) -> "only x matches"
_ -> "neither"
}
println(result) # "both match"
# In list patterns
target = 42
found = match [1, 42, 3] {
[_, ^target, _] -> true
_ -> false
}
println(found) # true
# Comparing two values - v binds, then ^v checks equality
a = 5
b = 5
same = match (a, b) {
(v, ^v) -> true
_ -> false
}
println(same) # true
}
Tip: The pin operator is inspired by Elixir/Erlang. Use it when you want to match against a known value stored in a variable, rather than creating a new binding.
Variable Constraints in Patterns
When an existing immutable variable appears in a pattern, it acts as a constraint rather than creating a new binding. The pattern only matches if the value equals the variable's current value. This enables Erlang-style pattern matching.
main() = {
expected = 5
# x acts as a constraint - must equal 5
result1 = match (5, 10) {
(expected, y) -> y * 2 # Matches! expected == 5
}
println(result1) # 20
# This won't match the first arm
result2 = match (3, 10) {
(expected, y) -> y * 2 # Doesn't match! 3 != expected
(_, y) -> y + 1 # Falls through to this
}
println(result2) # 11
}
This is especially useful for matching on expected values like status codes or message types:
handle_response(status, body) = {
ok_status = 200
match status {
ok_status -> "Success: " ++ body # Only if status == 200
404 -> "Not found"
s -> "Error: " ++ show(s)
}
}
Destructuring Records and Variants
You can destructure custom data types (records and variants) to extract their internal values.
type Point = { x: Int, y: Int }
# Destructure variants in function arguments
unwrap_some(Some(val)) = val
main() = {
p = Point(10, 20)
# Destructure records using match
x_val = match p {
{ x, y } -> x
}
println(x_val) # 10
# Option is built-in, use it directly
opt_val: Option[String] = Some("hello")
unwrapped = unwrap_some(opt_val) # "hello"
# Using match for comprehensive handling
description = match opt_val {
Some(s) -> "Contains: " ++ s
None() -> "Is empty"
}
println(description) # "Contains: hello"
}
Maps and Sets
You can use Maps and Sets with pattern matching by checking values after retrieval.
# Check map values using get and match
check_config(config: Map[String, Int]) = match config.get("level") {
0 -> "Disabled"
1 -> "Basic"
lvl -> "Level " ++ show(lvl)
}
# Check set membership
check_access(roles: Set[String]) =
if roles.contains("admin") then "Access granted"
else if roles.contains("editor") then "Partial access"
else "Access denied"
main() = {
conf = %{"level": 3, "timeout": 30}
println(check_config(conf)) # "Level 3"
user_roles = #{"user", "admin", "guest"}
println(check_access(user_roles)) # "Access granted"
}
Pattern Matching with Recursive Types
Pattern matching truly shines with recursive data structures like trees. Here's a binary tree with operations defined purely through pattern matching:
# A binary tree: either a leaf or a node with value and children
type Tree[T] = Leaf | Node(T, Tree[T], Tree[T])
# Count nodes in the tree
tree_size(Leaf) = 0
tree_size(Node(_, left, right)) = 1 + tree_size(left) + tree_size(right)
# Sum all values (for numeric trees)
tree_sum(Leaf) = 0
tree_sum(Node(val, left, right)) = val + tree_sum(left) + tree_sum(right)
# Check if a value exists in the tree
tree_contains(Leaf, _) = false
tree_contains(Node(val, _, _), target) when val == target = true
tree_contains(Node(_, left, right), target) = tree_contains(left, target) || tree_contains(right, target)
# Map a function over all values
tree_map(Leaf, _) = Leaf
tree_map(Node(val, left, right), f) = Node(f(val), tree_map(left, f), tree_map(right, f))
main() = {
# 5
# / \
# 3 8
# / / \
# 1 6 9
tree = Node(5,
Node(3, Node(1, Leaf, Leaf), Leaf),
Node(8, Node(6, Leaf, Leaf), Node(9, Leaf, Leaf))
)
assert_eq(tree_size(tree), 6)
assert_eq(tree_sum(tree), 32)
assert(tree_contains(tree, 6))
assert(tree_contains(tree, 100) == false)
# Double all values
doubled = tree_map(tree, x => x * 2)
assert_eq(tree_sum(doubled), 64)
println("Tree operations passed!")
}
Notice how each function is defined with clear, separate cases. There's no complex branching logic - just patterns that match the structure of the data.
Tip: Pattern matching encourages you to think about your data as shapes rather than as objects with methods. This often leads to simpler, more composable code.
Pattern Matching with Database Results
Pattern matching makes working with database query results clean and readable. PostgreSQL queries return rows as tuples, which you can destructure directly into named variables:
# Query returns tuples - destructure them into named variables
process_users(conn) = {
rows = Pg.query(conn, "SELECT id, name, email, age FROM users", [])
# Each row is a tuple (id, name, email, age)
# Pattern match to give each field a meaningful name
rows.each((id, name, email, age) => {
println("User #" ++ show(id) ++ ": " ++ name)
println(" Email: " ++ email)
println(" Age: " ++ show(age))
})
}
# Or use pattern matching in function definitions
format_user((id, name, email, _)) =
"User " ++ show(id) ++ ": " ++ name ++ " <" ++ email ++ ">"
# Handle different result shapes
handle_result([]) = println("No users found")
handle_result([(id, name, _, _)]) = println("Found one user: " ++ name)
handle_result(users) = println("Found " ++ show(users.length()) ++ " users")
# Combine with guards for conditional logic
categorize_user((_, _, _, age)) when age < 18 = "minor"
categorize_user((_, _, _, age)) when age < 65 = "adult"
categorize_user(_) = "senior"
main() = {
conn = Pg.connect("postgresql://localhost/mydb")
# Get rows and process with pattern matching
rows = Pg.query(conn, "SELECT id, name, score FROM players ORDER BY score DESC LIMIT 3", [])
# Destructure each row into named variables
rows.each((id, name, score) => {
println(name ++ " (ID: " ++ show(id) ++ ") - Score: " ++ show(score))
})
# Pattern match on the result list
match rows {
[] -> println("No players yet!")
[(_, winner, _)] -> println("Only player: " ++ winner)
[(_, first, _), (_, second, _) | _] -> println("Top 2: " ++ first ++ ", " ++ second)
}
Pg.close(conn)
}
Compare this to traditional database code where you access columns by index (row[0], row[1]) or string keys. Pattern matching gives you named access and validates the structure at the same time.
Index-based (error prone)
# Easy to mix up indices
for row in rows:
id = row[0]
name = row[1] # Was this 1 or 2?
email = row[2] # Hope the order is right...
Pattern matching (clear)
# Names are right in the pattern
rows.each((id, name, email) => {
# Can't mix them up!
println(name ++ ": " ++ email)
})