ExprLast updated on
Last updated on
Parse, evaluate, and type-check Paradoc expressions
@paradoc/expr is the engine behind Paradoc's expression language: the small language that powers visible, required, and computed values. This page documents the package API. For the language itself, its operators, functions, and fields.<id> addressing, see Logic.
The package gives you three things: a parse that turns source into a typed AST, an evaluate that runs an expression against data, and a check that type-checks an expression against an artifact's field types at authoring time. It also ships Decimal for exact decimal arithmetic, so money and percentage math never drift.
Installation
npm install @paradoc/exprThe same surface is re-exported through the SDK under an expr namespace, so you can reach it without a separate dependency:
// Direct import.
import { check, parse, evaluateExpression, Decimal } from '@paradoc/expr'
// Or via the SDK namespace.
import { expr } from '@paradoc/sdk'
expr.check(/* ... */)Parsing
parse returns { ast, errors }. The AST is null when the source has syntax errors; the errors carry positioned diagnostics.
import { parse } from '@paradoc/expr'
const { ast, errors } = parse('fields.age >= 18')
// ast is the typed expression tree; errors is emptyEvaluating
evaluateExpression parses and runs a source string against a context built with createContext. It returns a result, never throws on a bad expression. Values are tagged ({ kind, value }); use valueToString or the Values helpers to read them.
import { evaluateExpression, createContext, valueToString } from '@paradoc/expr'
const ctx = createContext({ fields: { age: 25 } })
const result = evaluateExpression('fields.age >= 18', ctx)
// { success: true, value: { kind: 'boolean', value: true } }
const sum = evaluateExpression('fields.age + 5', ctx)
if (sum.success) valueToString(sum.value) // "30"If you already have an AST, evaluate(ast, ctx) runs it directly.
evaluateExpression is the safe source API. Low-level evaluate, parseOrThrow, and direct Decimal operations may throw. Internal numbers remain decimals; converting them to JavaScript numbers is explicitly lossy. Division retains up to 20 fractional places. The engine bounds expression nesting, regex work, and decimal scale.
coalesce stops at the first non-null argument. Dates use canonical YYYY-MM-DD values, datetimes representing instants require Z or an explicit offset, and calendar month/year addition clamps to the last valid day. Diagnostic offsets count UTF-16 code units.
Checking
check is the authoring-time type checker. Its value is validate-as-you-type: as an author edits a visible or computed expression, check confirms every reference resolves and every operator and function is well-typed, surfacing positioned diagnostics before the form ever runs. This is what an editor lints with.
check(source, env) returns { type, diagnostics }. The env is a TypeEnv that maps reference paths to types. Build one from an artifact's field types so the checker knows what fields.age is, then catch references that don't exist.
import { check, createTypeEnv, T } from '@paradoc/expr'
// The host (e.g. @paradoc/core) builds this from real field definitions.
const env = createTypeEnv({
'fields.age': T.number,
'fields.country': T.string,
})
// A well-typed expression: no diagnostics, inferred as boolean.
check('fields.age >= 18', env)
// { type: { kind: 'boolean' }, diagnostics: [] }
// An unknown reference is caught at authoring time.
check('fields.age + fields.unknownField', env)
// diagnostics: [{
// name: 'fields.unknownField',
// severity: 'error',
// code: 'unknown-identifier',
// message: 'Unknown reference: fields.unknownField',
// span: { ... },
// }]For expressions used as gates (visible, required, include, rules), checkBooleanGate adds a non-boolean-gate error when the result isn't boolean. T is the type constructor (T.number, T.string, T.array(T.string), etc.) and createTypeEnv builds a simple path-to-type environment; hosts build richer ones.
Aggregates
sum, count, min, max, avg, any, and all read their first argument as a path into a list rather than as a value; the Logic page covers the language rules. Two things are the host's to supply.
Row visibility. Pass rowVisible to createContext to keep hidden rows out. It receives the list's path and the row's position at every list level, and every row counts when it is absent. @paradoc/core supplies it from the artifact's visible conditions.
const ctx = createContext(
{ fields: { items: [{ qty: 2 }, { qty: 5 }] } },
{ rowVisible: (listPath, indices) => !(listPath === 'fields.items' && indices[0] === 1) },
)
evaluateExpression('sum(fields.items.qty)', ctx) // 2: the second row is hiddenRow types. A TypeEnv types a list as T.array(...) and registers each item member under the list's own path, typed per row. The checker then knows that fields.items.qty is a number read from every row: accepted inside an aggregate, reported with the invalid-aggregate code anywhere else.
const env = createTypeEnv({
'fields.items': T.array(T.object),
'fields.items.qty': T.number,
})
check('sum(fields.items.qty)', env).type // { kind: 'number' }
check('fields.items.qty > 1', env).diagnostics[0].code // 'invalid-aggregate'A money sum over rows in more than one currency fails with the currency-mismatch evaluation code.
Exact decimals
Decimal is exact-decimal arithmetic backed by bigint. No floating point is used, so 19.95 * 3 is exactly 59.85 and money math doesn't accumulate rounding error. The constructor is private; build values with Decimal.fromString or Decimal.fromInt.
import { Decimal } from '@paradoc/expr'
Decimal.fromString('0.1').add(Decimal.fromString('0.2')).toString()
// "0.3" (not 0.30000000000000004)
Decimal.fromString('19.95').mul(Decimal.fromInt(3)).toString()
// "59.85"Decimal carries the usual operators (add, sub, mul, div, mod, cmp, round, and friends) and is the runtime representation of every number in the language.