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 by Context

Grove expressions access data through root objects. The available roots depend on the context where the expression appears.

RootAvailable inDescription
paramsActions (validate, apply)Incoming request parameters
recordValidators, invariants, actions, testsCurrent entity state
metaValidators, invariants, actionsSystem-provided context (time, user, etc.)
eventEvent apply hooksThe event being applied
inputWorkflow stepsInput 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.

PrecedenceOperator(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

MethodReturnDescription
.len()IntNumber of characters
.trim()StringRemove leading/trailing whitespace
.upper()StringConvert to uppercase
.lower()StringConvert to lowercase
.contains(s)BoolTrue if string contains s
.starts_with(s)BoolTrue if string starts with s
.ends_with(s)BoolTrue if string ends with s
.replace(old, new)StringReplace all occurrences of old with new
.split(sep)List<String>Split by separator
.substring(start, end)StringExtract substring (0-indexed, exclusive end)
.index_of(s)Int?Index of first occurrence, or null
.reverse()StringReverse the string
.is_empty()BoolTrue if length is 0
.is_blank()BoolTrue if empty or only whitespace
.pad_left(n, c)StringPad on the left to length n with char c
.pad_right(n, c)StringPad on the right to length n with char c
.chars()List<String>List of individual characters
.matches(pattern)BoolTest 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

MethodReturnDescription
.len()IntNumber of elements
.is_empty()BoolTrue if list has no elements
.contains(x)BoolTrue 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)BoolTrue if fn returns true for any element
.all(fn)BoolTrue 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()NumericSum of numeric elements
.min()T?Minimum element
.max()T?Maximum element
.join(sep)StringJoin 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)UReduce 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

MethodReturnDescription
.len()IntNumber of entries
.is_empty()BoolTrue if map has no entries
.contains_key(k)BoolTrue 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)

MethodReturnDescription
.abs()NumericAbsolute value
.round()IntRound to nearest integer
.floor()IntRound down
.ceil()IntRound up
.min(other)NumericSmaller of two values
.max(other)NumericLarger of two values
.clamp(lo, hi)NumericConstrain to range
.to_decimal()DecimalConvert Int to Decimal
.to_int()IntTruncate Decimal to Int
params.amount.abs()
(params.score / 10).round()
params.rating.clamp(1, 5)

Date and Time Methods

MethodReturnDescription
.year()IntYear component
.month()IntMonth (1-12)
.day()IntDay of month
.hour()IntHour (0-23), DateTime/Time only
.minute()IntMinute (0-59), DateTime/Time only
.second()IntSecond (0-59), DateTime/Time only
.day_of_week()IntDay of week (1=Mon, 7=Sun)
.add_days(n)Date/DateTimeAdd days
.add_hours(n)DateTimeAdd hours
.add_minutes(n)DateTimeAdd minutes
.add_months(n)Date/DateTimeAdd months
.add_years(n)Date/DateTimeAdd years
.diff_days(other)IntDays between two dates
.diff_hours(other)IntHours between two date-times
.to_epoch()EpochConvert to Unix epoch seconds
.format(pattern)StringFormat as string
meta.today().add_days(30)
record.created_at.diff_days(meta.today())
record.due_date.format("yyyy-MM-dd")

Duration Methods

MethodReturnDescription
.total_seconds()IntTotal seconds
.total_minutes()IntTotal minutes (truncated)
.total_hours()IntTotal hours (truncated)
.total_days()IntTotal 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

MethodReturnDescription
.to_string()StringConvert any value to string
.to_int()IntParse string or truncate decimal
.to_decimal()DecimalParse string or widen int
.to_bool()BoolParse string ("true"/"false")
.to_date()DateParse ISO date string
.to_datetime()DateTimeParse 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


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:

TypeDescription
Int64-bit signed integer
DecimalArbitrary-precision decimal
Booltrue or false
StringUTF-8 text
IdUnique identifier
DateCalendar date (no time)
TimeTime of day (no date)
DateTimeDate and time with timezone
DurationA span of time
EpochUnix timestamp (seconds)
List<T>Ordered collection
Map<K, V>Key-value collection
T?Optional (nullable)

See Also