Lesson 04: Custom Structs, Options & Pattern Matching
In this lesson, we explore Tinsel's data modeling and pattern matching capabilities: user-defined struct declarations, first-class Option values (Some(v) and None), and declarative match expressions.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Define custom data types and methods using
struct Name(fields...) { fn method(self)... }. - Use struct instances directly as
mapandsetkeys with zero-sort structural hashing. - Represent optional results using
Some(v)andNone, along with postfix?,!, and combinator methods. - Write expressive
matchexpressions using literal, range, tuple, slice, struct, map, string-template, and|or-patterns withifguards.
2. User-Defined struct Declarations
Tinsel makes defining custom domain types lightweight and ceremony-free.
Data-Only Structs & Default Fields
A struct declaration creates a constructor function with the same name. Fields can specify optional default values:
struct Circle(radius)
struct Rect(w, h)
struct Edge(from, to, weight = 1)
let c = Circle(5)
let e = Edge("A", "B")
assert(c.radius == 5)
assert(e.from == "A" && e.to == "B" && e.weight == 1)
assert(type_of(c) == "Circle")
assert(to_string(c) == "Circle(radius=5)")
Structs with Methods & Metamethods
Add a { ... } block to attach methods (taking self as the first parameter) or operator hooks (to_string, len, contains, index_get, index_set, iter):
struct Robot(pos, vel = vec2(1, 0)) {
fn step(self, ticks = 1) {
self.pos += self.vel * ticks
self
}
fn dist_from_origin(self) {
self.pos <-> vec2(0, 0)
}
fn to_string(self) {
f"Robot@{self.pos}(v={self.vel})"
}
}
let bot = Robot(vec2(2, 3), vec2(1, -1))
bot.step(4)
assert(bot.pos == vec2(6, -1))
assert(bot.dist_from_origin() == 7)
assert(to_string(bot) == "Robot@Vec2(6, -1)(v=Vec2(1, -1))")
Structural Equality & Zero-Sort Map/Set Hashing
Two struct instances of the same type are equal (==) if and only if all of their fields are equal. Because the VM hashes struct fields in declaration order without sorting keys, struct instances are ultra-fast keys in maps, sets, and BFS/Dijkstra visited tables:
import "std/set" as set
struct State(pos, keys_mask)
let s1 = State(vec2(1, 2), 0b011)
let s2 = State(vec2(1, 2), 0b011)
let s3 = State(vec2(1, 2), 0b111)
assert(s1 == s2 && s1 != s3)
let visited = set([s1, s2, s3])
assert(visited.len() == 2 && s1 in visited)
3. First-Class Option Values (Some & None)
In addition to nil, Tinsel provides a dedicated option type via the global constructors Some(value) and None:
opt?(postfix question operator) oropt.is_some(): ReturnstrueifoptisSome(v), orfalseifNone.opt!(postfix bang operator) oropt.unwrap(): ExtractsvfromSome(v)(panics ifNone).opt.unwrap_or(default): ReturnsvforSome(v), ordefaultforNone.- Combinators:
.map(fn),.and_then(fn),.filter(pred),.or_else(fallback_fn).
fn safe_div(a, b) {
if b == 0 { None } else { Some(a / b) }
}
let ok = safe_div(20, 4)
let empty = safe_div(20, 0)
assert(ok? && ok! == 5)
assert(!empty? && empty.unwrap_or(-1) == -1)
// Chaining option transformations
letchained = ok.map(x -> x * 10).filter(x -> x > 30)
assert(letchained == Some(50))
4. Declarative Pattern Matching (match)
match compares a target expression against a sequence of pattern arms pattern [if guard] -> expr and evaluates the first matching arm:
fn describe_char(ch) {
match ch {
'0'..='9' -> "digit",
'a'..='z' | 'A'..='Z' -> "letter",
'+' | '-' | '*' | '/' -> "operator",
_ -> "other"
}
}
assert(describe_char('7') == "digit")
assert(describe_char('Q') == "letter")
assert(describe_char('+') == "operator")
Rich Structural Patterns
match (as well as let destructuring) supports nine composable pattern forms:
| Pattern Kind | Syntax Example | Semantics |
|---|---|---|
| Wildcard | _ |
Matches any value without binding |
| Literal | 0, '#', "ok", true, nil, None |
Matches exact literal value |
| Range | 1..10, 'a'..='z' |
Matches integer or character within range |
| Binding & Equality | x, or repeated (x, x) |
Binds identifier; if x is bound multiple times in the same pattern, verifies all occurrences are equal! |
| Tuple | (x, y, 0) |
Matches fixed-length tuple/array and destructures elements |
Slice (..) |
[head, ..mid, tail] |
Matches array and captures prefix, middle rest slice, or suffix |
Struct & Some |
Circle(r), Point { x: 0, y }, Some(v) |
Matches struct type (positional or named fields) or Some option |
| Map | { type: "move", steps } |
Matches map containing specified keys and sub-patterns |
Or-Pattern (|) |
"left {n}" | "L {n}" |
Matches if any alternative matches (variables bound in all branches remain usable) |
struct Circle(r)
struct Rect(w, h)
fn classify_shape(s) {
match s {
Circle(0) -> "point circle",
Circle(r) if r > 0 -> f"circle(r={r})",
Rect(x, x) -> f"square({x}x{x})", // Repeated variable `x` requires w == h!
Rect(w, h) -> f"rect({w}x{h})",
_ -> "unknown"
}
}
assert(classify_shape(Rect(9, 9)) == "square(9x9)")
assert(classify_shape(Rect(4, 7)) == "rect(4x7)")
assert(classify_shape(Circle(3)) == "circle(r=3)")
Slice Patterns with Prefix, Middle, and Suffix Rest (..)
Slice patterns make recursive list processing and head/tail inspection effortless:
fn analyze_seq(arr) {
match arr {
[] -> "empty",
[only] -> f"single:{only}",
[first, ..middle, first] -> f"palindrome-ends:{first},mid={middle.len()}",
[head, ..tail] -> f"head:{head},rest={tail.len()}"
}
}
assert(analyze_seq([7, 1, 2, 3, 7]) == "palindrome-ends:7,mid=3")
assert(analyze_seq([10, 20, 30]) == "head:10,rest=2")
5. Common Pitfalls & Gotchas
Gotcha 1: Unmatched match Returns nil
If no arm in a match expression matches the input, the match expression evaluates to nil. Always include a wildcard _ -> ... arm when a fallback value is expected.
Gotcha 2: Repeated Pattern Variables Test Equality
Writing let (x, x) = pair or match p { (x, x) -> ... } succeeds only when both elements are equal. If you want to ignore multiple elements without comparing them, use _ ((x, _, _)).
6. Hands-On Exercises
Exercise 4.1: VM Instruction Interpreter (Warmup)
Define structs Add(n), Mul(n), and Reset(). Write a function run_ops(ops, start = 0) that folds over ops using match and computes the final accumulator value.
Solution & Explanation
struct Add(n)
struct Mul(n)
struct Reset()
fn apply_op(acc, op) {
match op {
Add(n) -> acc + n,
Mul(n) -> acc * n,
Reset() -> 0,
_ -> acc
}
}
let program = [Add(10), Mul(3), Reset(), Add(7), Mul(6)]
let result = program.fold(0, apply_op)
assert(result == 42)
println("Final accumulator:", result)
Exercise 4.2: Option Lookup Chain (Practice)
Write a function find_first_divisible(nums, divisor) that returns Some(n) for the first element divisible by divisor, or None if none match. Use .map() and .unwrap_or() to double the found number or fall back to -1.
Solution & Explanation
fn find_first_divisible(nums, divisor) {
for n in nums {
if n % divisor == 0 {
return Some(n)
}
}
None
}
let a = find_first_divisible([4, 9, 14, 21], 7).map(x -> x * 2).unwrap_or(-1)
let b = find_first_divisible([4, 9, 14, 21], 11).map(x -> x * 2).unwrap_or(-1)
assert(a == 28 && b == -1)
println("Results:", a, b)
Exercise 4.3: Recursive Slice Pattern Packet Evaluator (Challenge)
Use slice pattern matching ([op, a, b, ..rest]) to evaluate flat packets where 1 adds the next two numbers and 2 multiplies them, summing all packet results.
Solution & Explanation
fn eval_packets(stream) {
let mut s = stream
let mut total = 0
while s.len() > 0 {
match s {
[1, a, b, ..rest] -> {
total += a + b
s = rest
},
[2, a, b, ..rest] -> {
total += a * b
s = rest
},
_ -> { break }
}
}
total
}
assert(eval_packets([1, 10, 20, 2, 4, 5, 1, 3, 7]) == 60)
println("Packet total:", eval_packets([1, 10, 20, 2, 4, 5, 1, 3, 7]))
Next Steps
In Lesson 05: Input Ingestion, Template Patterns & File I/O, you will learn how to parse real-world puzzle inputs without regular expressions using string template patterns, .scan(), .ints(), and .sections().