Expressions
Expressions are Station's small, deterministic, side-effect-free language for the input mappings and when guards in dynamic broadcasts. They're pure functions of the broadcast's trigger input and upstream node outputs — no I/O, no time, no randomness, no loops, no user-defined functions. The persisted form is a JSON AST; a familiar string syntax exists for the dashboard and playground and compiles to the same AST.
Expressions ship in the station-expressions package. It has no dependencies on the rest of Station and is safe to use anywhere a small, sandboxable expression language is useful.
ExprNode
Six kinds of node make up the AST:
type ExprNode = | { kind: "ref"; path: string[] } | { kind: "lit"; value: unknown } | { kind: "tmpl"; parts: (string | ExprNode)[] } | { kind: "op"; op: BinaryOp | UnaryOp; args: ExprNode[] } | { kind: "obj"; entries: Record<string, ExprNode> } | { kind: "arr"; items: ExprNode[] };| Kind | Shape | Description |
|---|---|---|
ref | { path: string[] } | A path lookup against the evaluation context. See Reference paths. |
lit | { value: unknown } | A constant. Any JSON-serialisable value: number, string, boolean, null, array, or plain object. |
tmpl | { parts: (string | ExprNode)[] } | Template literal. Interleaves string fragments with embedded expressions and concatenates them — every embedded value is coerced to its string form. |
op | { op: string; args: ExprNode[] } | Operator application. Unary ops have a single arg; binary ops have two. |
obj | { entries: Record<string, ExprNode> } | Object construction. Each value is an expression that evaluates into the corresponding key. |
arr | { items: ExprNode[] } | Array construction. |
Reference paths
A ref resolves against an evaluation context with two roots: input (the broadcast's trigger input) and upstream (an object keyed by upstream node name).
| Path | Resolves to |
|---|---|
["input", "orderId"] | The broadcast trigger input's orderId field. |
["upstream", "validate", "total"] | The validate node's output, field total. |
["validate", "total"] | Shorthand — any first segment that is not input is treated as upstream.<name>. |
Missing paths return undefined rather than throwing — the evaluator never panics on a typo at runtime. The validator catches missing-property errors at save time, against the signal schemas.
Operators
| Operator | Arity | Notes |
|---|---|---|
== != | binary | Strict equality (=== / !== in JS terms). |
< > <= >= | binary | Numeric or string comparison. |
&& || | binary | Short-circuit boolean logic. |
! | unary | Boolean negation. |
+ | binary | Overloaded: if either operand is a string, the result is a string concatenation; otherwise numeric addition. |
- * / | binary | Numeric. |
That's the full operator set. There are no bitwise operators, no modulo, no ternary, no spread. If you need them, push the logic into a signal.
String syntax
The parser accepts a familiar string form and compiles to the same AST. It's what the dashboard's expression editor produces and what the POST /expressions/parse endpoint exposes.
import { parse, stringify } from "station-expressions"; parse('input.amount > 100 && input.user.tier == "premium"');// → { kind: "op", op: "&&", args: [...] } stringify(node);// → '(input.amount > 100)'Parsing is lossless against the AST: parse(stringify(n)) produces a structurally identical node.
Evaluation
import { evaluate } from "station-expressions"; const node = { kind: "op", op: ">", args: [ { kind: "ref", path: ["input", "amount"] }, { kind: "lit", value: 100 }, ],}; evaluate(node, { input: { amount: 250 }, upstream: {} });// → trueEvaluation is bounded by MAX_NODES = 10_000 — a runaway expression throws ExpressionEvalError rather than spinning. There are no closures, no captured state, no global access.
Validation
validate type-checks an expression against the schemas derived from a signal's Zod input/output types. It's what the broadcast-definition save endpoint runs before it accepts a spec.
import { validate } from "station-expressions"; validate(node, { inputSchema: { type: "object", properties: { amount: { type: "number" } }, }, upstreamSchemas: {}, expectedSchema: { type: "boolean" },});// → { ok: true, errors: [] }Behaviour around unknown and any
Schema fields with type: "any" opt out of type-checking — refs into them are treated as compatible with anything, and operator type rules are relaxed when either side is any. This is what lets you write expressions against signals whose schemas haven't been narrowed (or against the broadcast trigger input when you haven't declared an explicit schema).
Schema fields with type: "unknown", by contrast, propagate as errors when used in operator positions that require a concrete type. Use any when you genuinely don't want the validator to check; use unknown when you want it to force an upstream type narrowing.
HTTP API
The Station server exposes the expression toolkit under /api/v1/expressions for clients (mainly the dashboard) to parse, evaluate and validate without re-implementing the language.
| Endpoint | Body | Returns |
|---|---|---|
POST /api/v1/expressions/parse | { "source": "input.x > 0" } | { "node": ExprNode } on success; 400 with parse error details on failure. |
POST /api/v1/expressions/evaluate | { "node": ExprNode, "context": { input, upstream } } | { "value": unknown } |
POST /api/v1/expressions/validate | { "node": ExprNode, "inputSchema": SchemaField, "upstreamSchemas": {...}, "expectedSchema": SchemaField } | { "ok": boolean, "errors": [...] } |
Determinism guarantees
evaluateis total over inputs that passvalidateagainst the real schemas — it never throws on type mismatches at runtime if the validator passed.- Bounded by
MAX_NODES = 10_000. Larger expressions throw rather than running. - No closures, no captured state, no access to the host environment. Two evaluations with the same AST and context return the same value.
The escape hatch
If you can't express something in this language — you need a loop, a regex, an HTTP call, a clever transform — write a code-defined signal that does the logic in TypeScript and reference it from your broadcast graph. The signal is Station's unit of arbitrary code; expressions are for connecting signals together. Don't fight the language to embed logic that belongs in a signal.