Pattern Matching

Pattern matching is Kit's primary control flow mechanism. It allows you to examine a value's structure and extract data while simultaneously controlling program flow. This guide covers all of Kit's pattern matching features.

Match Expressions

A match expression examines a value against a series of patterns, executing the code associated with the first pattern that matches:

# Basic match syntax
describe-number = fn(n) =>
  match n
  | 0 -> "zero"
  | 1 -> "one"
  | 2 -> "two"
  | _ -> "other"

println (describe-number 1)  # => one
println (describe-number 5)  # => other

Each pattern is introduced with | followed by the pattern and -> before the result expression:

match value
| pattern1 -> expression1
| pattern2 -> expression2
| pattern3 -> expression3

Literal Patterns

Literal patterns match exact values including numbers, strings, and booleans:

# Match numbers
factorial = fn(n) =>
  match n
  | 0 -> 1
  | 1 -> 1
  | n -> n * factorial (n - 1)

# Match strings
greet = fn(lang) =>
  match lang
  | "english" -> "Hello"
  | "spanish" -> "Hola"
  | "french" -> "Bonjour"
  | _ -> "Hi"

# Match booleans
toggle = fn(b) =>
  match b
  | true -> false
  | false -> true

println (greet "spanish")  # => Hola
println (toggle true)      # => false

Variable Patterns

Variable patterns bind the matched value to a name, making it available in the pattern's expression:

# Bind to a variable
classify = fn(n) =>
  match n
  | 0 -> "zero"
  | x -> "non-zero: ${x}"

println (classify 0)   # => zero
println (classify 42)  # => non-zero: 42

# Use bound variables in calculations
absolute = fn(n) =>
  match n
  | 0 -> 0
  | x -> if x < 0 then -x else x

Wildcard Pattern

The wildcard pattern _ matches any value but doesn't bind it to a name. Use it as a catch-all pattern or when you don't need the matched value:

# Catch-all pattern
is-small? = fn(n) =>
  match n
  | 0 -> true
  | 1 -> true
  | 2 -> true
  | _ -> false

# Ignore values you don't need
always-42 = fn(x) =>
  match x
  | _ -> 42

println (is-small? 1)     # => true
println (is-small? 100)   # => false
println (always-42 "anything")  # => 42

List Patterns

List patterns allow you to match on list structure and destructure lists into their components:

# Match empty list
is-empty? = fn(list) =>
  match list
  | [] -> true
  | _ -> false

# Destructure head and tail
first = fn(list) =>
  match list
  | [] -> None
  | [x | _] -> Some x

# Recursive list processing
sum = fn(list) =>
  match list
  | [] -> 0
  | [x | rest] -> x + sum rest

# Match multiple elements
starts-with-zero? = fn(list) =>
  match list
  | [0 | _] -> true
  | _ -> false

println (sum [1, 2, 3])  # => 6
println (is-empty? [])    # => true

You can nest list patterns to access deeper structure:

# Match nested list structure
second = fn(list) =>
  match list
  | [] -> None
  | [_] -> None
  | [_ | [x | _]] -> Some x

println (second [1, 2, 3])  # => Some 2
println (second [1])        # => None

Tuple Patterns

Tuple patterns destructure tuples, extracting their components:

# Destructure pairs
swap = fn(tuple) =>
  match tuple
  | (a, b) -> (b, a)

# Match specific tuple values
is-origin? = fn(point) =>
  match point
  | (0, 0) -> true
  | _ -> false

# Extract values from tuples
distance-from-origin = fn(point) =>
  match point
  | (x, y) -> sqrt (x * x + y * y)

# Nested tuple patterns
line-length = fn(line) =>
  match line
  | ((x1, y1), (x2, y2)) ->
      dx = x2 - x1
      dy = y2 - y1
      sqrt (dx * dx + dy * dy)

println (swap (1, 2))        # => (2, 1)
println (is-origin? (0, 0))  # => true

Constructor Patterns

Constructor patterns match algebraic data type (ADT) variants and extract their associated data:

# Define ADTs
type Option a = Some a | None
type Result a = Ok a | Err String

# Match Option variants
unwrap-or = fn(opt, default) =>
  match opt
  | Some x -> x
  | None -> default

# Match Result variants
handle-result = fn(result) =>
  match result
  | Ok value -> "Success: ${value}"
  | Err msg -> "Error: ${msg}"

# More complex ADT
type Tree a = Leaf a | Branch (Tree a) (Tree a)

tree-sum = fn(tree) =>
  match tree
  | Leaf x -> x
  | Branch left right -> tree-sum left + tree-sum right

my-tree = Branch (Leaf 1) (Branch (Leaf 2) (Leaf 3))
println (tree-sum my-tree)  # => 6

Constructor patterns can be combined with other patterns:

# Combine constructor and list patterns
first-ok = fn(results) =>
  match results
  | [] -> None
  | [Ok x | _] -> Some x
  | [_ | rest] -> first-ok rest

results = [Err "failed", Ok 42, Ok 100]
println (first-ok results)  # => Some 42

Guards

Guards add additional conditions to patterns using if. A pattern with a guard only matches when both the pattern and the guard condition are satisfied:

# Basic guard
classify-number = fn(n) =>
  match n
  | x if x < 0 -> "negative"
  | 0 -> "zero"
  | x if x > 0 -> "positive"

# Multiple guards
describe-age = fn(age) =>
  match age
  | n if n < 0 -> "invalid"
  | n if n < 13 -> "child"
  | n if n < 20 -> "teenager"
  | n if n < 65 -> "adult"
  | _ -> "senior"

println (classify-number -5)  # => negative
println (describe-age 15)     # => teenager

Guards work with any pattern type:

# Guards with list patterns
first-positive = fn(list) =>
  match list
  | [] -> None
  | [x | _] if x > 0 -> Some x
  | [_ | rest] -> first-positive rest

# Guards with constructor patterns
filter-small-ok = fn(result) =>
  match result
  | Ok x if x < 10 -> Ok x
  | Ok x -> Err "too large"
  | err -> err

println (first-positive [-1, -2, 3, 4])  # => Some 3

Exhaustiveness

Kit ensures that match expressions are exhaustive, meaning they handle all possible cases. This prevents runtime errors from unhandled values:

# Non-exhaustive match (compile error!)
bad-example = fn(opt) =>
  match opt
  | Some x -> x
  # Missing: | None -> ...

# Exhaustive match (correct)
good-example = fn(opt) =>
  match opt
  | Some x -> x
  | None -> 0

Use wildcard patterns to ensure exhaustiveness:

# Handle all remaining cases with wildcard
safe-classify = fn(value) =>
  match value
  | 0 -> "zero"
  | 1 -> "one"
  | _ -> "other"  # Handles all other numbers

Match Macros

Kit provides three concise match macros that simplify common pattern matching scenarios: is, as, and guard. These keywords expand into full match expressions at compile time.

The is Macro

The is macro checks if a value matches a pattern, returning a boolean. It's perfect for conditionals where you just need to test a pattern.

# Syntax: expr is Pattern
# Returns: true if expr matches Pattern, false otherwise

# Check ADT variant
is-passed? = status is Passed

# Use in conditionals
if result.status is Passed then "ok" else "fail"

# Check with nested patterns
has-name? = user is Some {name: "Alice", ..}

# Check non-empty list
has-items? = list is [_ | _]

# Combine with boolean operators
both-ok? = result1 is Ok _ and result2 is Ok _

The is macro expands to a match expression:

# This:
status is Passed

# Expands to:
match status
| Passed -> true
| _ -> false

The as Macro

The as macro extracts a value when a pattern matches, with a default value otherwise. It's useful for accessing data inside ADTs without verbose match blocks.

# Syntax: expr as Pattern -> consequent else alternative

# Extract from Option with default
name = get-user(id) as Some {name, ..} -> name else "Anonymous"

# Extract from Result
stdout = run-result as Ok {stdout, ..} -> stdout else ""

# Transform the matched value
doubled = opt as Some n -> n * 2 else 0

# Access list head with default
first = items as [x | _] -> x else 0

The as macro expands to a match expression:

# This:
get-user(id) as Some u -> u.name else "Anonymous"

# Expands to:
match get-user(id)
| Some u -> u.name
| _ -> "Anonymous"

The guard Macro

The guard macro binds a pattern or returns early from a function. It's similar to Swift's guard statement and complements Kit's ?! operator, but allows custom return values and works with any pattern.

# Syntax: guard Pattern = expr else return-expr
# Binds pattern variables if match succeeds, returns early otherwise

process-user = fn(id) =>
  # Bind or return early
  guard Some user = get-user(id) else Err "User not found"
  guard Ok validated = validate(user) else Err "Validation failed"

  # Continue with bound variables
  Ok (transform validated)

# Compared to nested matches:
process-user-verbose = fn(id) =>
  match get-user(id)
  | Some user ->
    match validate(user)
    | Ok validated -> Ok (transform validated)
    | Err e -> Err "Validation failed"
  | None -> Err "User not found"

Note: The guard macro is particularly useful for reducing nesting in functions that need multiple pattern checks before proceeding. Compare it with the ?! operator which propagates errors unchanged:

# ?! propagates the original error
process = fn(path) =>
  content = File.read(path) ?!  # Returns original Err if read fails
  Ok content

# guard allows custom error values
process = fn(path) =>
  guard Ok content = File.read(path) else Err "Could not read ${path}"
  Ok content

Practical Examples

Pattern matching shines in real-world scenarios. Here are some practical examples:

JSON-like Data Processing

type JsonValue =
  | JNull
  | JBool Bool
  | JNumber Int
  | JString String
  | JArray [JsonValue]
  | JObject [(String, JsonValue)]

stringify = fn(json) =>
  match json
  | JNull -> "null"
  | JBool true -> "true"
  | JBool false -> "false"
  | JNumber n -> to-string n
  | JString s -> "\"${s}\""
  | JArray items ->
      strs = map stringify items
      "[${join \", \" strs}]"
  | JObject pairs ->
      format-pair = fn((k, v)) => "\"${k}\": ${stringify v}"
      strs = map format-pair pairs
      "{${join \", \" strs}}"

State Machine

type State = Idle | Running | Paused | Stopped
type Event = Start | Pause | Resume | Stop

transition = fn(state, event) =>
  match (state, event)
  | (Idle, Start) -> Running
  | (Running, Pause) -> Paused
  | (Paused, Resume) -> Running
  | (Running, Stop) -> Stopped
  | (Paused, Stop) -> Stopped
  | (s, _) -> s  # Invalid transitions keep current state

current = Idle
current = transition current Start   # Running
current = transition current Pause   # Paused
current = transition current Resume  # Running

List Operations

# Take first n elements
take = fn(n, list) =>
  match (n, list)
  | (0, _) -> []
  | (_, []) -> []
  | (n, [x | rest]) -> cons x (take (n - 1) rest)

# Drop first n elements
drop = fn(n, list) =>
  match (n, list)
  | (0, xs) -> xs
  | (_, []) -> []
  | (n, [_ | rest]) -> drop (n - 1) rest

# Zip two lists together
zip = fn(xs, ys) =>
  match (xs, ys)
  | ([], _) -> []
  | (_, []) -> []
  | ([x | xs-rest], [y | ys-rest]) ->
      cons (x, y) (zip xs-rest ys-rest)

println (take 3 [1, 2, 3, 4, 5])  # => [1, 2, 3]
println (zip [1, 2] ["a", "b"])      # => [(1, "a"), (2, "b")]

Best Practices

  • Always be exhaustive: Ensure your match expressions handle all possible cases to prevent runtime errors.
  • Order patterns carefully: Patterns are checked top-to-bottom; place more specific patterns before general ones.
  • Use guards for complex conditions: When a simple pattern isn't enough, guards keep your code clear.
  • Prefer pattern matching over if-else: Pattern matching is more expressive and safer for complex conditionals.
  • Name bound variables clearly: Use descriptive names for variables bound in patterns.
  • Use wildcards when appropriate: If you don't need a value, use _ to signal intent.

Next Steps

Now that you understand pattern matching, explore related topics: