Rhai
Scripting with Rhai
When a calculation or reshaping is more than the visual tools can express, write it in Rhai: a small, safe scripting language that looks like a mix of JavaScript and Rust. Rhai runs inside a sandbox with no file, network or system access, and with limits on work and memory, so a script works only on the data its step gives it.
Where you can use Rhai
| Place | What you write | Variables you can use |
|---|---|---|
Expression in any mapper or step input (source Expression, or type = in a value) | One expression; its value becomes the field. | item (read-only), steps, run |
| Code step, Each record mode | A script run once per record. | item (changeable), steps, run |
| Code step, All records mode | A script run once over the whole list. | items (read-only list), steps, run |
| Transform Modules | Reusable fn definitions imported by Code steps. | Only the function parameters |
Inside a Loop body you can also read loop_index, loop_item, loop_state and loop_input. Conditions on edges, filters and loops are not Rhai; they use the field/operator/value form.
The variables
item: the current record as an object.item.email,item.address.city,item.lines[0].sku. A field that does not exist reads as empty().items: all records, as a list (All records mode).steps: the output of earlier steps by step id. Each is a list:steps["lookup"][0].price. Values saved with Set value are insteps["$values"][0].name.run:run.id,run.started_at, and for scheduled runsrun.occurrence,run.occurrence_utc,run.previous_occurrence,run.time_zone.
Always use the variable name. Write item.price * item.qty, not price * qty. Expressions are checked when you save, and a bare field name is rejected as an unknown variable.
Language basics
| You want | Write |
|---|---|
| A variable | let total = item.qty * item.unit_price; |
| A choice (if is an expression) | let label = if total > 1000 { "large" } else { "small" }; |
| A list | let tags = ["new", "vip"]; |
An object (note the #) | let customer = #{ name: item.name, email: item.email }; |
| Text with placeholders (backticks) | let greeting = `Hello ${item.name}!`; |
| A loop | for line in item.lines { total += line.amount; } (also while and loop) |
| A switch | let hours = switch item.priority { "urgent" => 4, "high" => 8, _ => 24 }; |
| A function | fn cents(v) { (v * 100.0).to_int() } |
| A fallback when a field is missing or empty | if type_of(item.nickname) == "()" || item.nickname == "" { item.name } else { item.nickname } |
| Stop with your own error | throw "amount is missing"; |
Numbers are whole (42) or decimal (42.0); mixing them works. Text uses double quotes. The last expression of a script or block is its value, and return ends early. Statements end with ;.
Function reference
Text
| Function | Example | Result |
|---|---|---|
len, is_empty | "abc".len() | 3 |
to_upper, to_lower | item.country.to_upper() | "DE" |
trim * | let s = item.name; s.trim(); s | text without outer spaces |
replace * | let s = item.phone; s.replace(" ", ""); s | spaces removed |
contains, starts_with, ends_with | item.email.ends_with("@acme.com") | true |
index_of | "a-b".index_of("-") | 1 (or -1) |
sub_string(start, length) | item.sku.sub_string(0, 3) | first three characters |
split | "a,b,c".split(",") | ["a", "b", "c"] |
pad *, truncate *, crop * | let s = id; s.pad(8, "0"); s | padded to 8 characters |
+, to_string | item.first + " " + item.last | joined text |
parse_int, parse_float | parse_float("12.50") | 12.5 |
* These change the variable in place and return nothing, so copy the value into a variable first, change it, then use the variable.
Numbers
abs, floor, ceiling, round, round_up, round_down, round_half_up, to_int, to_float, sqrt, exp, ln, log, sin/cos/tan, min, max, sign, is_even, is_odd, PI(), E(), and the operators + - * / % **. Round to two decimals with (x * 100.0).round() / 100.0.
Lists
len, push, pop, insert, remove, contains, index_of, filter, map, reduce, some, all, find, sort (optionally with a comparison), reverse, dedup, truncate, extract, retain, zip, and ranges 0..10. Closures use pipes: items.filter(|r| r.amount > 0).
Objects
keys, values, len, contains, remove (returns the removed value), mixin (merge another object in), to_json (object to JSON text), parse_json(text) (JSON object text to an object), and type_of(x) ("string", "i64", "f64", "map", "array", "()"...).
Dates
date_now(), format_date, parse_date, add_minutes, add_hours, add_days, add_weeks, add_months, as described in Working with dates. The number passed to the add_ functions must be a whole number. Example: format_date(add_days(date_now(), -1), "%Y-%m-%d") is yesterday's date.
Large values
Very large values (a long text, a large list or object, a file) are carried by reference instead of in the record. On such a value use len(), byte_len(), sha256(), is_text() and is_json(); reading its content, or a field or position inside it, fails with a message that says so. Returning the value unchanged passes it on intact.
Outside scripts the same holds. Steps that only pass values on, such as mappings, carry a large value unchanged. Steps that need its content, such as filters, sorting or text changes, stop with a clear message instead of treating it as empty. A destination either sends the whole value or stops with a message naming what it could not send; it never sends the reference in its place.
What a Code step returns
- Each record: change
itemand end with a statement, and the changeditemis the output; or end with an object#{ ... }to output exactly that. Returning a list or a plain value is an error; switch to All records to emit several records. - All records: the script's final value is the output. A list becomes that many records; anything else becomes one record.
itemsis read-only:for r in items { r.x = 1 }changes copies, so useitems.map(...)and return the result, or copy first withlet out = items;.
Examples
Each record: calculate and tag
item.gross = (item.net * 1.19 * 100.0).round() / 100.0;
item.sla_hours = switch item.priority { "urgent" => 4, "high" => 8, _ => 24 };Each record: output only some fields
#{ label: item.name, total: item.quantity * item.unit_price }Each record: clean an email address
let e = item.email; e.trim(); item.email = e.to_lower();Each record: enrich from another step
let m = steps["customers"].filter(|r| r.id == item.customer_id);
if m.len() > 0 { item.customer_name = m[0].name }Each record: dates
item.due = add_days(item.created_at, 30);
item.due_label = format_date(item.due, "%d.%m.%Y", "Europe/Berlin");All records: add a field to every record
items.map(|r| { r.imported_at = date_now(); r })All records: remove duplicates by email
let seen = #{};
items.filter(|r| { let k = r.email; if seen.contains(k) { false } else { seen[k] = true; true } })All records: count per email
let groups = #{};
for r in items { let k = r.email; if groups.contains(k) { groups[k] += 1 } else { groups[k] = 1 } }
let out = [];
for k in groups.keys() { out.push(#{ email: k, count: groups[k] }); }
outAll records: top three by amount
let out = items;
out.sort(|a, b| if a.amount < b.amount { 1 } else if a.amount > b.amount { -1 } else { 0 });
out.truncate(3);
outAll records: handle text that should be a number
items.map(|t| {
let a = if type_of(t.amount) == "string" { parse_float(t.amount) } else { t.amount };
t.priority = if a >= 5000 { 5 } else if a >= 1000 { 3 } else { 1 };
t
})Expressions in the mapper
item.price * item.qty
steps["lookup"][0].price * item.qty
if item.amount > 100 { "vip" } else { "standard" }
item.first_name + " " + item.last_name
format_date(item.created_at, "%Y-%m-%d")Using a Transform Module
Module money contains fn cents(value) { (value * 100.0).to_int() }. A Code step lists money under Imported modules and uses item.cents = cents(item.amount). Modules can be used in Code steps (not in single mapper expressions), and the step preview does not load them; test module calls with a flow test run.
Limits and errors
| Limit | Value |
|---|---|
| Work per expression | 50,000 operations per evaluation |
| Work per record (Each record) | 500,000 operations |
| Work per step (All records) | 5,000,000 operations |
| Text length | 5,000,000 characters |
| List or object size | 1,000,000 entries |
| Nesting / function call depth | 128 / 64 |
| Script size | 100 KB (Code step and Transform Module) |
| Not available | import, eval, clock functions other than the date functions above, file, network and system access |
Errors show the message and its line and position, for example Variable not found: price (line 1, position 1), Function not found: ..., Too many operations (make the script do less work per record, or split the job), or your own throw text. The Transform Studio shows Compiled or Syntax error as you type, and Live output shows the result on sample data. A Code step on a branch that receives no records does not run.
Let Matt write it. In Transform Studio, Write with Matt turns a sentence into a checked expression, and on a Code step Matt for Code drafts a complete Rhai script that must compile and is tested against sample data before you can apply it. See Matt.