Skip to main content

Pattern Matching and Updates

Two operators let a script branch on the shape of a value and rewrite a structure. A match chooses a result by testing a subject against a list of cases. An update produces a new structure with selected paths changed, leaving the original untouched. Both are postfix operators that take a brace block. They bind looser than and and or but tighter than a ternary or if. See the precedence table for where they sit.

The Match Expression

A match is subject match { … }. The block is a sequence of case clauses and an optional else. Each clause supplies a -> result that becomes the value of the whole expression when that clause is the first to fit. The arrow is the same -> used by lambdas.

subject match {
  case <pattern-or-condition> [if guard] -> result
  case …
  else -> fallback
}

Cases are tried top to bottom, and the first that fits wins. If no case matches and there is no else, TransformScript reports an error.

Inside any case body the matched value is available through the binding _, which is the idiomatic way to refer to it.

Value (Equality) Cases

The plainest case is an expression. It is evaluated and compared to the subject, and the case fits when they are equal. When the expression is a boolean or a callable, the case fits when it is truthy or returns truthy. A case can be a literal, a computed value, or a predicate over _.

5 match { case 5 -> "five" else -> "other" }
// → "five"

5 match { case 2 + 3 -> "computed five" else -> "no" }
// → "computed five" (the case expression evaluates to 5)

5 match { case _ > 3 -> "big" else -> "small" }
// → "big" (the case is a truthy predicate, not an equality test)

8 match { case _ > 0 -> _ * 10 else -> 0 }
// → 80

Binding a Value

Prefix a case with name: to bind the matched value to name for use in the guard and result. The text after the colon is an ordinary value pattern (equality).

"hi" match { case s: "hi" -> upper(s) else -> "no" }
// → "HI"

Type Patterns

case is Type fits when the subject is of that type. Add a binding name in front to capture the value. The recognized type names are String, Number, Boolean, Null, Array, Object, Regex, Period, Date, Time, LocalTime, DateTime, LocalDateTime, and TimeZone. See Types and Coercion.

"x" match { case is Number -> "num" case is String -> "str" else -> "?" }
// → "str"

42 match { case n is Number -> n + 1 else -> 0 }
// → 43

Regex Patterns

case matches /regex/ coerces the subject to a string and fits when the pattern matches anywhere in it. With a binding name, the bound value is the capture array: element 0 is the whole match, followed by each capture group.

"abc123" match { case matches /[0-9]+/ -> "has digits" else -> "no" }
// → "has digits"

"2024-01" match { case m matches /([0-9]+)-([0-9]+)/ -> m else -> "no" }
// → ["2024-01", "2024", "01"]

The pattern after matches may also be an expression that evaluates to a Regex or a String.

Destructuring Patterns

An array pattern [head ~ tail] fits a non-empty array, binding the first element and the rest. [] fits only the empty array. An object pattern {key: value ~ tail} fits a non-empty object, binding the first entry's key and value and the remaining entries, while {} fits only the empty object. The ~ here is the destructuring separator.

[1, 2, 3] match { case [h ~ t] -> { head: h, tail: t } else -> "empty" }
// → { head: 1, tail: [2, 3] }

[] match { case [] -> "empty" else -> "nonempty" }
// → "empty"

{ a: 1, b: 2 } match { case {k: v ~ rest} -> { firstKey: k, firstVal: v, rest: rest } else -> "no" }
// → { firstKey: "a", firstVal: 1, rest: { b: 2 } }

Guards

Any case may carry a trailing if guard. The guard runs only after the case's pattern or condition fits and after its binding is in scope. The case is taken only when the guard is truthy. A guard with a bare binding name and no other pattern lets you bind first, then filter.

7 match { case n is Number if n > 5 -> "big" else -> "small" }
// → "big"

3 match { case n is Number if n > 5 -> "big" else -> "small" }
// → "small"

10 match { case n if n > 5 -> n else -> 0 }
// → 10

The Else Branch

else -> result is the catch-all. It is syntactically optional, but because an unmatched subject reports an error, a real script almost always supplies one.

99 match { case 5 -> "five" }
// → reports an error: no match cases matched the value

An Example

Selecting a discount rate from a tier field reads naturally as value cases plus an else.

// payload = { "tier": "silver" }
payload.tier match {
  case "gold"   -> 0.2
  case "silver" -> 0.1
  else          -> 0
}
// → 0.1

The Update Operator

update is subject update { … }, where the subject must be an Object or Array. It walks each case, follows that case's selector into a deep copy of the subject, and replaces the value found there with the case's result. The original value is never changed, so update always returns a new structure. If no case applied, the original subject is returned unchanged.

subject update {
  case [name at] <selector> [!] [if condition] -> result
  case …
}

Cases are applied in order, and each sees the structure as left by the cases before it.

The Selector Path

A case's selector starts with . and is a path of field steps and array index steps, the same field and index forms described in Selecting and Navigating Data. It addresses the single location to write. Field lookups match string or symbol keys, and negative array indices count from the end.

// payload = { "price": 50, "name": "x" }
payload update { case .price -> $ * 2 }
// → { "price": 100, "name": "x" }

// payload = { "customer": { "name": "acme", "id": 1 } }
payload update { case .customer.name -> upper($) }
// → { "customer": { "name": "ACME", "id": 1 } }

// payload = { "items": [1, 2, 3] }
payload update { case .items[0] -> $ * 100 }
// → { "items": [100, 2, 3] }

The Existing Value

Inside a case body, $ is bound to the current value at the selected path, the value about to be overwritten, so a result can be expressed as a transform of what was there. You may also bind that value to a name with the name at .selector form. The name and $ then both refer to it.

// payload = { "status": "open" }
payload update { case old at .status -> "was " ++ old }
// → { "status": "was open" }

// payload = { "qty": 5 }
payload update { case orig at .qty -> orig + $ }
// → { "qty": 10 }

Conditional Updates

A trailing if condition gates the write. The condition is evaluated with $ and any binding in scope, and the path is rewritten only when the condition is truthy. Otherwise the case is skipped and the path keeps its value.

// payload = { "price": 200 }
payload update { case .price if $ > 100 -> $ * 0.9 }
// → { "price": 180.0 }

// payload = { "price": 50 }
payload update { case .price if $ > 100 -> $ * 0.9 }
// → { "price": 50 }   (condition false; left unchanged)

Upsert

By default, a selector whose path does not exist matches nothing and the case is silently skipped. Append ! after the selector to upsert: missing keys and missing intermediate containers are created, then assigned. Inside the body $ is null for a freshly created path.

// payload = { "name": "x" }   (no nickname)
payload update { case .nickname -> "n/a" }
// → { "name": "x" }           (path missing, case skipped)

payload update { case .nickname! -> "n/a" }
// → { "name": "x", "nickname": "n/a" }

// nested path created end to end
payload update { case .meta.source! -> "fts" }
// → { "name": "x", "meta": { "source": "fts" } }

An Example

Apply a volume discount only above a threshold, leaving small orders alone.

// payload = { "total": 150 }
payload update { case .total if $ > 100 -> $ * 0.9 }
// → { "total": 135.0 }