Expressions Deep Dive
Expressions are the computational core of Grove. Every validator, invariant, apply block, and test assertion is built from expressions. This page is a comprehensive reference for the expression language -- from simple arithmetic to method chains, pattern matching, and lambda syntax.
Prerequisites: Basic familiarity with Grove modules, records, and actions. See Build a Task Tracker for an introductory tutorial.
What you'll learn:
- Root objects available in each context
- Operator precedence
- Method chains for every built-in type
- String interpolation
- If expressions and match expressions
- Lambda syntax
- Let bindings
- For loops
- Object and list literals
Root Objects by Context
Grove expressions access data through root objects. The available roots depend on the context where the expression appears.
| Root | Available in | Description |
|---|---|---|
params | Actions (validate, apply) | Incoming request parameters |
record | Validators, invariants, actions, tests | Current entity state |
meta | Validators, invariants, actions | System-provided context (time, user, etc.) |
event | Event apply hooks | The event being applied |
input | Workflow steps | Input data for the current step |
The params root
Inside an action, params holds the fields declared in the action signature:
action set_name {
first_name: String
last_name: String
apply {
let full = "${params.first_name} ${params.last_name}"
emit name_set {
full_name: full
}
}
}
The record root
The record root provides access to the current state of the entity. In
validators it reflects the state before the action runs. In invariants it
reflects the state after events have been applied.
validate is_active {
record.status == Status.active
}
The meta root
meta provides system-level functions:
meta.now() // DateTime -- current timestamp
meta.today() // Date -- current date
meta.user() // String -- authenticated user identifier
meta.request_id() // String -- unique request identifier
The event root
Inside event apply hooks, event gives access to the event fields:
event.amount // a field on the event
event.reason // another field
The input root
In workflow step definitions, input holds the data passed to the step:
input.order_id
input.items
Operator Precedence
Operators are listed from highest to lowest precedence. Operators at the same level associate left to right unless noted.
| Precedence | Operator(s) | Description |
|---|---|---|
| 1 | . ?. () [] | Member access, optional chain, call, index |
| 2 | ! - (unary) | Logical NOT, numeric negation |
| 3 | * / % | Multiplication, division, modulo |
| 4 | + - | Addition, subtraction |
| 5 | < <= > >= | Comparison |
| 6 | == != | Equality |
| 7 | && | Logical AND |
| 8 | || | Logical OR |
| 9 | ?? | Null coalescing |
| 10 | |> | Pipe |
Use parentheses to override precedence:
(a + b) * c
!(x && y)
Optional chaining and null coalescing
The ?. operator short-circuits to null when the left side is null. The
?? operator provides a fallback value when the left side is null.
record.assignee?.name ?? "unassigned"
record.metadata?.tags?.len() ?? 0
The pipe operator
The |> operator passes the left-hand value as the first argument to the
right-hand function or method:
params.name |> trim() |> upper()
This is equivalent to params.name.trim().upper() but can improve readability
in longer chains.
Method Chains
Grove provides built-in methods on every type. Methods are called with dot syntax and can be chained.
String Methods
| Method | Return | Description |
|---|---|---|
.len() | Int | Number of characters |
.trim() | String | Remove leading/trailing whitespace |
.upper() | String | Convert to uppercase |
.lower() | String | Convert to lowercase |
.contains(s) | Bool | True if string contains s |
.starts_with(s) | Bool | True if string starts with s |
.ends_with(s) | Bool | True if string ends with s |
.replace(old, new) | String | Replace all occurrences of old with new |
.split(sep) | List<String> | Split by separator |
.substring(start, end) | String | Extract substring (0-indexed, exclusive end) |
.index_of(s) | Int? | Index of first occurrence, or null |
.reverse() | String | Reverse the string |
.is_empty() | Bool | True if length is 0 |
.is_blank() | Bool | True if empty or only whitespace |
.pad_left(n, c) | String | Pad on the left to length n with char c |
.pad_right(n, c) | String | Pad on the right to length n with char c |
.chars() | List<String> | List of individual characters |
.matches(pattern) | Bool | Test against a regex pattern |
params.email.trim().lower()
record.title.len() > 0
"hello world".split(" ") // ["hello", "world"]
"groove".contains("grove") // false
"2024-001".pad_left(10, "0") // "002024-001"
List Methods
| Method | Return | Description |
|---|---|---|
.len() | Int | Number of elements |
.is_empty() | Bool | True if list has no elements |
.contains(x) | Bool | True if list contains x |
.first() | T? | First element or null |
.last() | T? | Last element or null |
.get(i) | T? | Element at index i or null |
.map(fn) | List<U> | Transform each element |
.filter(fn) | List<T> | Keep elements where fn returns true |
.find(fn) | T? | First element where fn returns true |
.any(fn) | Bool | True if fn returns true for any element |
.all(fn) | Bool | True if fn returns true for all elements |
.flat_map(fn) | List<U> | Map then flatten one level |
.flatten() | List<T> | Flatten one level of nesting |
.distinct() | List<T> | Remove duplicates, preserving order |
.sort() | List<T> | Sort in natural order |
.sort_by(fn) | List<T> | Sort by key function |
.reverse() | List<T> | Reverse order |
.take(n) | List<T> | First n elements |
.skip(n) | List<T> | Skip first n elements |
.sum() | Numeric | Sum of numeric elements |
.min() | T? | Minimum element |
.max() | T? | Maximum element |
.join(sep) | String | Join elements with separator |
.zip(other) | List<(T, U)> | Pair elements with another list |
.enumerate() | List<(Int, T)> | Pair each element with its index |
.group_by(fn) | Map<K, List<T>> | Group by key function |
.fold(init, fn) | U | Reduce to a single value |
record.tags.contains("urgent")
record.items.map(|item| item.price * item.qty).sum()
record.scores.filter(|s| s > 80).len()
["a", "b", "c"].join(", ") // "a, b, c"
[3, 1, 2].sort() // [1, 2, 3]
Map Methods
| Method | Return | Description |
|---|---|---|
.len() | Int | Number of entries |
.is_empty() | Bool | True if map has no entries |
.contains_key(k) | Bool | True if key exists |
.get(k) | V? | Value for key or null |
.keys() | List<K> | All keys |
.values() | List<V> | All values |
.entries() | List<(K, V)> | All key-value pairs |
.map_values(fn) | Map<K, U> | Transform values |
.filter(fn) | Map<K, V> | Keep entries where fn returns true |
.merge(other) | Map<K, V> | Merge with another map (right wins) |
record.metadata.get("region") ?? "us-east"
record.scores.entries().filter(|e| e.1 > 90).len()
Numeric Methods (Int and Decimal)
| Method | Return | Description |
|---|---|---|
.abs() | Numeric | Absolute value |
.round() | Int | Round to nearest integer |
.floor() | Int | Round down |
.ceil() | Int | Round up |
.min(other) | Numeric | Smaller of two values |
.max(other) | Numeric | Larger of two values |
.clamp(lo, hi) | Numeric | Constrain to range |
.to_decimal() | Decimal | Convert Int to Decimal |
.to_int() | Int | Truncate Decimal to Int |
params.amount.abs()
(params.score / 10).round()
params.rating.clamp(1, 5)
Date and Time Methods
| Method | Return | Description |
|---|---|---|
.year() | Int | Year component |
.month() | Int | Month (1-12) |
.day() | Int | Day of month |
.hour() | Int | Hour (0-23), DateTime/Time only |
.minute() | Int | Minute (0-59), DateTime/Time only |
.second() | Int | Second (0-59), DateTime/Time only |
.day_of_week() | Int | Day of week (1=Mon, 7=Sun) |
.add_days(n) | Date/DateTime | Add days |
.add_hours(n) | DateTime | Add hours |
.add_minutes(n) | DateTime | Add minutes |
.add_months(n) | Date/DateTime | Add months |
.add_years(n) | Date/DateTime | Add years |
.diff_days(other) | Int | Days between two dates |
.diff_hours(other) | Int | Hours between two date-times |
.to_epoch() | Epoch | Convert to Unix epoch seconds |
.format(pattern) | String | Format as string |
meta.today().add_days(30)
record.created_at.diff_days(meta.today())
record.due_date.format("yyyy-MM-dd")
Duration Methods
| Method | Return | Description |
|---|---|---|
.total_seconds() | Int | Total seconds |
.total_minutes() | Int | Total minutes (truncated) |
.total_hours() | Int | Total hours (truncated) |
.total_days() | Int | Total days (truncated) |
Crypto Methods
Crypto functions are available as free functions, not methods:
hash_sha256(value) // String -- SHA-256 hex digest
hash_md5(value) // String -- MD5 hex digest
hmac_sha256(key, value) // String -- HMAC-SHA256 hex digest
uuid() // String -- random UUID v4
Coercion Methods
| Method | Return | Description |
|---|---|---|
.to_string() | String | Convert any value to string |
.to_int() | Int | Parse string or truncate decimal |
.to_decimal() | Decimal | Parse string or widen int |
.to_bool() | Bool | Parse string ("true"/"false") |
.to_date() | Date | Parse ISO date string |
.to_datetime() | DateTime | Parse ISO datetime string |
"42".to_int() // 42
42.to_string() // "42"
"2025-01-15".to_date() // Date value
String Interpolation
Grove uses ${} syntax inside double-quoted strings to embed expressions:
"Hello, ${params.name}!"
"Total: ${record.items.map(|i| i.price).sum()}"
"Due in ${record.due_date.diff_days(meta.today())} days"
Any expression can appear inside ${}. The result is converted to a string
automatically. Interpolation works in fail messages, event field values,
and anywhere a string expression is expected.
Nested quotes inside interpolation use the opposite quote style or escape:
"Key: ${record.metadata.get("region") ?? "unknown"}"
If Expressions
if in Grove is an expression -- it evaluates to a value. Every if must
have an else branch when used as an expression.
let label = if record.priority == Priority.high {
"URGENT"
} else {
"normal"
}
Chained else-if
let tier = if record.score >= 90 {
"gold"
} else if record.score >= 70 {
"silver"
} else {
"bronze"
}
If as a statement
Inside apply blocks, if can be used as a statement (without capturing the
result) to conditionally emit events or fail:
action adjust_inventory {
quantity: Int
apply {
if params.quantity < 0 && record.stock + params.quantity < 0 {
fail "Cannot reduce stock below zero"
}
emit inventory_adjusted {
quantity: params.quantity
}
if record.stock + params.quantity <= record.reorder_threshold {
emit reorder_triggered {
current_stock: record.stock + params.quantity
}
}
}
}
Match Expressions
match provides exhaustive pattern matching over enums, types, and values.
Matching enums
let max_seats = match record.plan {
Plan.free => 1
Plan.basic => 5
Plan.premium => 50
Plan.enterprise => 10000
}
Enum matches must be exhaustive -- every variant must be covered. The compiler will reject a match that is missing variants.
Matching with guards
let message = match record.status {
Status.active if record.days_remaining < 7 => "Expiring soon"
Status.active => "Active"
Status.expired => "Expired"
Status.cancelled => "Cancelled"
}
Matching values
let category = match params.code.substring(0, 2) {
"US" => "domestic"
"CA" => "canada"
"MX" => "mexico"
_ => "international"
}
The _ wildcard matches any value not covered by previous arms.
Match in validators
validate plan_allows_feature {
match record.plan {
Plan.free => false
Plan.basic => record.feature_count <= 10
Plan.premium => true
Plan.enterprise => true
}
}
Lambda Syntax
Lambdas use pipe-delimited parameters: |param| body. They are used as
arguments to list and map methods.
Single parameter
record.items.filter(|item| item.active)
record.tags.map(|tag| tag.upper())
Multiple parameters
record.values.fold(0, |acc, val| acc + val)
record.items.zip(record.prices).map(|item, price| item.name + ": " + price.to_string())
Multi-line lambdas
For complex logic, use a block body:
record.items.map(|item| {
let discount = if item.qty > 10 { 0.1 } else { 0.0 }
item.price * item.qty * (1.0 - discount)
})
Lambdas in sort_by and group_by
record.tasks.sort_by(|t| t.due_date)
record.orders.group_by(|o| o.region)
Let Bindings
let introduces an immutable binding. Once bound, the name cannot be
reassigned.
let subtotal = record.items.map(|i| i.price * i.qty).sum()
let tax = subtotal * record.tax_rate
let total = subtotal + tax
Let bindings are available in apply blocks, inline validators, invariants,
and match arms.
Destructuring
Let bindings do not currently support destructuring. Access tuple elements with positional syntax:
let pair = record.items.first()
// pair.0 is the first element, pair.1 is the second
Scope
Let bindings are scoped to the enclosing block. A binding inside an if branch
is not visible outside it:
if params.amount > 100 {
let discount = params.amount * 0.1
// discount is available here
}
// discount is NOT available here
For Loops
for loops are available only inside action apply blocks. They iterate
over lists and can emit events for each element. For loops cannot be nested more
than 3 levels deep.
action process_line_items {
items: List<LineItem>
apply {
for item in params.items {
emit line_item_added {
product_id: item.product_id
quantity: item.quantity
price: item.price
}
}
}
}
Conditional emission in loops
action reconcile_payments {
payments: List<Payment>
apply {
for payment in params.payments {
if payment.status == PaymentStatus.failed {
emit payment_flagged {
payment_id: payment.id
reason: "Payment failed during reconciliation"
}
}
}
}
}
Nested loops (max depth 3)
action import_catalog {
categories: List<Category>
apply {
for category in params.categories {
for product in category.products {
emit product_imported {
category_name: category.name
product_name: product.name
sku: product.sku
}
}
}
}
}
A third level of nesting is permitted but a fourth is a compile-time error.
Restrictions
- For loops are not available in validators, invariants, or test assertions.
Use list methods (
.any(),.all(),.filter()) instead. - The loop variable is immutable.
breakandcontinueare not supported. Use.filter()before the loop to exclude elements.
Object and List Literals
List literals
Lists are written with square brackets:
let empty: List<Int> = []
let numbers = [1, 2, 3, 4, 5]
let names = ["alice", "bob", "carol"]
List elements must all have the same type. The type is inferred from the elements or from context.
Object literals
Object literals construct type or event instances using brace syntax:
emit comment_added {
author: meta.user()
body: params.body
posted_at: meta.now()
}
In event emissions the field names match the event declaration. For type instances used as values:
let addr = Address {
street: params.street
city: params.city
state: params.state
zip: params.zip
}
Map literals
Maps are constructed with Map syntax:
let config = Map {
"timeout" => 30
"retries" => 3
"region" => "us-east"
}
Map keys must be String, Int, or enum values. Values must all share the same
type.
Nested literals
Literals can be nested:
let order = Order {
items: [
LineItem { sku: "A001", qty: 2 },
LineItem { sku: "B002", qty: 1 }
]
metadata: Map {
"source" => "web"
"campaign" => "spring_sale"
}
}
Type Reference
For completeness, here are all built-in types available in expressions:
| Type | Description |
|---|---|
Int | 64-bit signed integer |
Decimal | Arbitrary-precision decimal |
Bool | true or false |
String | UTF-8 text |
Id | Unique identifier |
Date | Calendar date (no time) |
Time | Time of day (no date) |
DateTime | Date and time with timezone |
Duration | A span of time |
Epoch | Unix timestamp (seconds) |
List<T> | Ordered collection |
Map<K, V> | Key-value collection |
T? | Optional (nullable) |
See Also
- Build a Task Tracker -- see expressions in action in a complete project
- Validation & State Machines -- expressions in validators and invariants
- Events & Actions -- the
applyblock context where for loops and fail are available - Types & Enums -- detailed reference for the type system