A parsed, validated, reusable safe expression — SoHL's way to evaluate a condition that was written as a string (for example, a predicate a GM types into an action's trigger field, or an Active Effect's test) without the dangers of eval.

It exists because such strings come from data, not source code: they must be evaluated against live game objects, yet must never be able to run arbitrary code, reach the DOM or network, or escape through the prototype chain. SafeExpression parses the string into a syntax tree, statically validates it against a strict allowlist, then evaluates that tree by hand. Anything outside the allowed language is rejected — usually before it ever runs.

It is a SohlEntity: only its source is persisted (via toJSON); the parsed AST is rebuilt in the constructor on revival.

For a hands-on authoring guide — the grammar, the full standard-helper reference (exact signatures and return values), the bindings each call site provides, and worked examples — see the Expressions and Scripts concept doc. The summary below covers the essentials.

Two steps: build once, then evaluate as often as you like.

  1. Constructnew SafeExpression({ source }, { parent }) parses and validates source immediately. If the string uses anything unsupported it throws a SafeExpressionError right here, so a bad predicate fails loudly at setup time instead of silently at use time. Construction is the costly step; keep the instance and reuse it.
  2. Evaluateexpr.evaluate(context?) runs the expression against context, a plain object of variable bindings. Every bare identifier in the expression is looked up by name in context. It returns whatever the expression computes (for a predicate, a boolean; for a computed field, a number or string).
// A simple predicate. `level` and `injured` are read from the context object.
const expr = new SafeExpression({ source: "level >= 3 && !injured" }, { parent });
expr.evaluate({ level: 5, injured: false }); // true
expr.evaluate({ level: 2, injured: false }); // false
expr.evaluate({ level: 9, injured: true }); // false

An expression's identifiers only mean something against the bindings its call site supplies, and that contract used to be implicit: each site built an ad-hoc object literal, so writing an identifier the site did not bind parsed cleanly and only threw at evaluation — where the caller caught it, logged a warning, and silently treated the feature as off (issue #1142).

Pass an ExpressionScope to close that gap. The scope declares the legal identifiers, and construction rejects anything outside them:

const scope = expressionScopes.require("skill.base");
new SafeExpression({ source: "sb(strength)" }, { parent, scope });
// ✗ throws: Unknown identifier "strength" in the "skill.base" scope;
// available identifiers: attr

Only the root identifier of a member chain is checked — itemLogic.foo.bar validates itemLogic and leaves the object graph alone, since the roots are a knowable list and the graph is not. The scope is optional: an expression built without one accepts any identifier and resolves it from the evaluation context, exactly as before.

Allowed: literals (3, "orc", true), array literals ([1, 2]), identifiers resolved from the context, property access by dot or bracket (actor.name, tags["ranged"]), the operators === !== < > <= >= + - * / %, the short-circuiting && and ||, the unary ! - +, the ternary cond ? a : b, and calls to helpers (below).

Rejected — at parse/validation time, before anything runs: assignment (=), bitwise and loose-equality operators (& | == !=), typeof / new / delete / instanceof, statements (;, if, for), template and regex literals, and — importantly — method calls. You cannot write actor.die(); the only callable values are the registered helpers.

Because method calls are banned, helpers are how you expose behavior to an expression. They come from the global expressionHelpers registry — the built-in library plus any world-loaded custom helpers — and are resolved by name when the expression is validated and evaluated.

// `has` and `len` are built-in helpers; the expression may call them by name.
const expr = new SafeExpression(
{ source: "has('ranged', tags) && len(tags) <= 3" },
{ parent },
);
expr.evaluate({ tags: ["ranged", "magic"] }); // true

Every failure — a parse error, an unsupported node, an unknown identifier at evaluation, or an attempt to extract a method — surfaces as a SafeExpressionError. Syntax and validation failures throw from the constructor; runtime failures throw from evaluate.

Hierarchy (View Summary)

Constructors

Properties

scope: undefined | ExpressionScope

The call site's declared bindings, when one was supplied. Identifiers outside it are rejected at construction; without a scope, any identifier is accepted and resolved from the evaluation context. Transient — the scope belongs to the call site, not to the persisted expression.

source: string

The original expression source string.

Accessors

  • get kind(): string
  • The serialization discriminator for this instance — the concrete class's static Kind. Written into the JSON by toJSON under the kind key and read back by sohl.utils.defaultFromJSON to select the constructor. Derived from the class, never stored per-instance.

    Returns string

  • get parent(): SohlLogic<any>
  • The Logic that owns this entity. Always present (the constructor rejects a missing parent) and transient — it is not serialized and is re-supplied when the entity is revived or cloned.

    Returns SohlLogic<any>

Methods

  • The distinct attribute shortcodes an expression reads via the attr context namespace (attr.str, attr["dex"]) — the memberRefs walk specialized to "attr". Lowercased and de-duplicated.

    Returns string[]

    The referenced attribute shortcodes, in first-seen order.

  • The distinct member names read off base within the arguments of calls to callee, anywhere in the expression — e.g. callArgMemberRefs("sb") on sb(attr.str, attr.dex) + attr.aur / 10 yields ["str", "dex"], excluding the attr.aur that sits outside the call.

    Where memberRefs answers "which members does this expression reference", this answers "which members does this call consume" — the distinction between an attribute a Skill Base is based on and one that merely adjusts the result (#1175). Argument order is preserved, so the first name returned is the call's primary argument.

    Nested calls inside the arguments are descended into, and repeated calls to the same helper are unioned. Names are lowercased and de-duplicated, in first-seen order.

    Parameters

    • callee: string

      The helper name whose call arguments to inspect (e.g. "sb").

    • base: string = "attr"

      The base identifier whose member accesses to collect (defaults to "attr").

    Returns string[]

    The distinct, lowercased member names, in first-seen order; empty when callee is never called.

  • Evaluate the expression against a set of variable bindings.

    Parameters

    • context: Record<string, unknown> = {}

      Variable bindings available to the expression.

    Returns unknown

    The value the expression evaluates to.

    If evaluation references an unknown identifier, references a helper without calling it, accesses a denied key, reads a method (function-valued property), or a called helper throws. Any non-SafeExpressionError is wrapped as one.

  • The distinct member names read off a given base identifier anywhere in the expression — e.g. attrRefs() returns every attr.<name> accessed. Both dot access (attr.str) and string-literal computed access (attr["str"]) are collected; the names are lowercased and de-duplicated, in first-seen order. Computed access by a non-literal key (attr[x]) cannot be resolved statically and is ignored.

    A read-only static walk over the already-validated AST (no re-parse). Used to derive a skill's attribute dependencies from its Skill-Base expression — e.g. gating fate off when the formula reads attr.aur — without a regex on the source.

    Parameters

    • base: string = "attr"

      The base identifier whose member accesses to collect (defaults to "attr").

    Returns string[]

    The distinct, lowercased member names, in first-seen order.

  • Statically check whether source is a well-formed, allowlist-valid expression — without evaluating it or requiring a live parent Logic.

    This is the shared validation seam for editing surfaces: a DataModel field's _validateType, or the live "is this valid?" feedback in the expression editor dialog. It runs the exact same parse-and-allowlist path the runtime uses at construction (a bad operator, a denied key, a method or unregistered-helper call, or a parse failure all fail here), so editor validity never drifts from runtime behavior. Only the constructor's static checks run; the parent is a throwaway stub because static validation never reads it (only evaluate does, for parent-bound helpers).

    A blank, whitespace-only, null, or undefined source is treated as valid (unset) — an empty formula field means "no expression", not a broken one.

    Parameters

    • source: undefined | null | string

      The expression text to check (or blank/nullish for unset).

    • Optionalscope: ExpressionScope

      The call site's declared bindings, when known. Supplying it also rejects an identifier outside the scope, so the editor flags an out-of-scope name as you type rather than leaving it to fail at runtime.

    Returns undefined | string

    undefined when the source is valid or unset; otherwise the SafeExpressionError message describing why it is invalid.