Every args value in a model is an expression string — a JS-like formula evaluated against the model's parameters and node keys. packages/parametric/src/expression.ts is a small, self-contained interpreter: parse with jsep plus three plugins, evaluate a typed AST directly (no eval, no codegen), and stringify back to source for AST-safe rewrites. The engine never runs untrusted JS — the interpreter only understands the node types below.
Parsing
import { parse } from "@parametron/parametric"
const ast = parse("width - 2 * thickness")parse is jsep itself, re-exported after registering three plugins at module load:
@jsep-plugin/ternary—cond ? a : b@jsep-plugin/object—{ a: 1, b: 2 }@jsep-plugin/arrow—(p, i) => p[2] > 0
The Expression type (re-exported from jsep) is the loose AST shape ({ type: string, [k: string]: any }); expression.ts also carries narrower internal views per node type (Identifier, BinaryExpression, CallExpression, …) but those aren't exported — treat Expression as opaque and pass it straight to evaluate/stringify.
Evaluating
import { evaluate, parse, type EvalContext } from "@parametron/parametric"
import { nodeNamespaces } from "@parametron/parametric/nodes"
const ast = parse("Point.distance([0,0,0], [width, 0, 0])")
const result = evaluate(ast, { width: 300 }, nodeNamespaces)export function evaluate(
node: Expression,
context: EvalContext = {},
namespaces?: Record<string, unknown>
): unknowncontext is a plain Record<string, unknown> (EvalContext) — usually parameter/node values keyed by key. namespaces is the registry's PascalCase computation namespaces (Point, Vector, Curve, …, see Node catalog) for the owning model; it's a function argument, not module state, so two models on different registries evaluate independently in the same process.
Scope resolution, low to high priority: JS builtins → namespaces → context. Builtins are fixed: Math, Number, String, Boolean, Array, Object, isNaN, parseFloat, parseInt — the same list the schema calls out as reserved names (a node key can't shadow one). context properties may be lazy getters (Object.defineProperty) — evaluate copies property descriptors, not values, so a getter isn't invoked unless the expression actually reads it. This is how Model wires dependency tracking: an ExpressionNode's context getter registers itself as a dependent the moment an expression touches a given key.
Supported syntax
The evaluator is a switch over jsep's node types — every case below is implemented; anything else throws Unsupported node type.
| Node type | Example | Notes |
|---|---|---|
Literal | 42, 'Oak', true, null | strings are single-quoted in source |
Identifier | width | looked up in context/namespaces/builtins; unresolved → ReferenceError |
BinaryExpression | a + b, a ** b, a && b | + - * / % **, comparisons (< <= > >=), == === != !==, || && |
UnaryExpression | -x, !flag, ~n | + - ! ~ |
ConditionalExpression | flag ? 10 : 20 | ternary plugin |
MemberExpression | foo.bar, foo[i] | dotted or computed |
CallExpression | Math.max(a, b), arr.map(f) | this is preserved for method calls (str.padStart(...), arr.map(...)) so instance methods work; a bare/namespaced call ignores it |
ArrayExpression | [0, 0, height / 2] | |
ObjectExpression | { a: 1, b: 2 } | object plugin |
ArrowFunctionExpression | (p, i) => i % 2 === 0 | arrow plugin — used for filter/lambda args (vertexFilter, arrayLinear items, …) |
Compound | a; b | sequence, returns the last value |
Notably absent because the model schema doesn't need them: assignment, loops, var/let/const, new, typeof/instanceof, try/catch, function declarations, regex literals, tagged templates.
Stringify
import { parse, stringify } from "@parametron/parametric"
stringify(parse("a - (b - c)")) // "a - (b - c)" — parens preserved (right operand of `-`)
stringify(parse("(a ** b) ** c")) // "(a ** b) ** c" — parens preserved (left operand of `**`)
stringify(parse("(a * b) + c")) // "a * b + c" — redundant parens droppedstringify(node, parentPrec = 0) walks the same AST back to source, re-parenthesizing only where precedence/associativity requires it — ** is right-associative, everything else left-associative, and array/object/identifier/literal nodes bind as tightly as member access so [1,2].map(...) never grows a defensive wrapper. Round-tripping through parse → mutate AST → stringify is how the engine rewrites expressions programmatically without a text/regex pass.
Renaming references
import { renameIdentifier } from "@parametron/parametric"
renameIdentifier("thickness * 2", "thickness", "wallThickness")
// "wallThickness * 2"
renameIdentifier("foo.length", "length", "size")
// "foo.length" — unchanged: a non-computed member property is a label, not a reference
renameIdentifier("[1, 2, 3].map((length) => length * 2)", "length", "size")
// unchanged — the arrow's own `length` param shadows the outer nameexport function renameIdentifier(expr: string, oldName: string, newName: string): stringWhen a node key is renamed, every other expression in the model that references it needs the same rewrite — renameIdentifier does that as an AST-safe transform, not a string replace, so it never touches a name used as a label: a non-computed member property (foo.length), an ObjectExpression key ({ length: 3 }), or an arrow function's own parameter. It also respects local shadowing — a reference inside an arrow body is skipped when one of that arrow's own params shadows oldName. Returns expr unchanged (never throws) if the string doesn't contain oldName, doesn't parse, or fails to re-stringify — safe to call speculatively during a rename, including on a mid-edit/invalid expression.
Errors
The evaluator throws plain JS errors — the caller (ExpressionNode.compute(), see Model & pull graph) is responsible for catching and surfacing them:
ReferenceError: Undefined variable: <name>— anIdentifiernot found incontext/namespaces/builtins.TypeError: Not a function— aCallExpressioncallee that isn't callable.Error: Unsupported operator: <op>/Unsupported unary: <op>— a jsep-parsed operator with no evaluator case (shouldn't occur with the plugin set above).Error: Unsupported node type: <type>— same, for a whole AST node kind.parse()itself throws jsep's own syntax errors on malformed source.
See also
- Model & pull graph — how
ExpressionNodebuilds the per-expression scope (parameter/node keys, dependency tracking, circular-dependency detection) and wherenamespacescomes from at evaluation time. - Node catalog — the
Point.*/Vector.*/Curve.*/Surface.*/Face.*computation namespaces available inside expressions. - Model authoring reference — the full expression scope/literal/lambda authoring guide, and the
expressiontype in the model schema. - Registry & contracts —
RegistryInput.namespaces, the mechanism a host uses to register its own PascalCase functions.