❄️ Tinsel AoC DSL

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 map and set keys with zero-sort structural hashing.
  • Represent optional results using Some(v) and None, along with postfix ?, !, and combinator methods.
  • Write expressive match expressions using literal, range, tuple, slice, struct, map, string-template, and | or-patterns with if guards.

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:

tinsel
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):

tinsel
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:

tinsel
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) or opt.is_some(): Returns true if opt is Some(v), or false if None.
  • opt! (postfix bang operator) or opt.unwrap(): Extracts v from Some(v) (panics if None).
  • opt.unwrap_or(default): Returns v for Some(v), or default for None.
  • Combinators: .map(fn), .and_then(fn), .filter(pred), .or_else(fallback_fn).
tinsel
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:

tinsel
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)
tinsel
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:

tinsel
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

Warning

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.

Warning

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
tinsel
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
tinsel
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
tinsel
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().