LogicLast updated on
Last updated on
Conditional visibility, validation rules, and expressions
Logic adds dynamic behavior to forms. Instead of static structures, you can define rules that control when fields appear, when they're required, and what values are valid.
Logic is defined at design time but evaluated at runtime, when the form is filled with data. Expressions are written in @paradoc/expr, a small typed language built for intra-artifact logic. Authoring checks and runtime evaluation use the same function and value contract.
Where Logic Applies
Logic expressions can control:
visible— whether a field or annex appearsrequired— whether a field or annex must be filled
const form = p.form()
.name("application")
.fields({
hasVehicle: { type: "boolean", label: "Do you have a vehicle?" },
vehicleMake: {
type: "text",
label: "Vehicle Make",
visible: "fields.hasVehicle",
required: "fields.hasVehicle",
},
})
.build()When hasVehicle is true, vehicleMake becomes visible and required. When false, it's hidden.
The same language runs in every text, Markdown, HTML, and DOCX template: block conditions, loop sources, and placeholders are expressions, so fields.hasVehicle means the same in a template as it does here. See Templates.
Referencing Fields
A field's value is addressed as fields.<id>, for example fields.hasVehicle or fields.age. The reference resolves to the field's value directly; there is no .value suffix. Composite fields expose their parts with a further segment, e.g. fields.rent.amount and fields.rent.currency for a money field.
// A boolean field is the boolean itself
visible: "fields.hasVehicle"
// A money field's parts
visible: "fields.rent.amount > 2000"Validation rules (rules) are the one exception: there, field values are also available unprefixed (creditScore as well as fields.creditScore).
Parties are addressed as parties.<role>: parties.buyer.name, parties.seller.legalName. A role that allows several parties is a list, so aggregates and row references apply to it, as in count(parties.tenant) > 1. A key that is not an identifier, such as a hyphenated checklist item, is read with brackets: items["signed-contract"].
Row References
Inside a list item, item refers to the current row. Use it in the visible and required conditions of the item and of the fields inside it:
const form = p.form()
.name("expenses")
.fields({
lines: {
type: "list",
item: {
type: "fieldset",
fields: {
kind: { type: "enum", enum: [{ value: "travel" }, { value: "other" }] },
explanation: {
type: "text",
visible: "item.kind == 'other'",
required: "item.kind == 'other'",
},
},
},
},
})
.build()Each row is evaluated on its own, so only the rows whose kind is other ask for an explanation. The reference follows the row when rows are added, removed, or reordered.
In a list nested inside a list item, item is the inner row and parent is the row that contains it:
// A part's note is required when its line is flagged and the part costs more than 100
note: { type: "text", required: "parent.flagged and item.cost > 100" }A row whose item is a single value (for example a list of number) exposes that value as item, and composite values keep their parts (item.amount, item.currency). The row's other fields and the rest of the form stay available through fields.<id> and defs.
item and parent are reserved. The checker rejects item outside a list item, parent outside an item of a nested list, and a def named item or parent. Defs, rules, annexes, parties, and the list field's own conditions are outside any row.
Operators
// Comparison: ==, !=, >, >=, <, <=
visible: "fields.country == 'USA'"
// Logical: and, or, not
visible: "fields.isOwner or fields.isAgent"
required: "fields.age >= 18 and fields.hasLicense"
visible: "not fields.isMinor"
// Membership: in, not in
visible: "fields.state in ['CA', 'NY', 'TX']"
required: "fields.role not in ['guest', 'observer']"
// Arithmetic: + - * / % (+ is polymorphic)
required: "fields.income + fields.bonus > 50000"
// Ternary
visible: "fields.tier == 'gold' ? fields.optedIn : false"+ is polymorphic: it adds numbers and concatenates strings, resolved by operand type. Use it to build display strings (fields.firstName + ' ' + fields.lastName); there is no || concatenation operator.
== is equality; = is not an operator. && and || are not part of the language; write and / or.
Functions
Strings:
| Function | Returns | Notes |
|---|---|---|
contains(haystack, needle) | boolean | substring / membership test |
startsWith(value, prefix) | boolean | |
endsWith(value, suffix) | boolean | |
matches(value, pattern) | boolean | bounded regular-expression subset: literals, classes, anchors, and wildcards; no repetition, alternation, assertions, or backreferences |
trim(value) | string | |
lower(value) / upper(value) | string | |
length(value) | number | |
isEmpty(value) / isNotEmpty(value) | boolean | |
coalesce(a, b, …) | first non-null | evaluates left to right and skips unused alternatives |
Numbers (decimal-aware):
| Function | Returns |
|---|---|
round(value, digits?) | number |
floor(value) / ceil(value) / abs(value) | number |
min(a, b, …) / max(a, b, …) | number; absent (null) arguments are skipped, and all absent is null |
Dates:
| Function | Returns | Notes |
|---|---|---|
today() / now() | date / datetime | the as-of clock, supplied by the host |
yearsBetween(from, to) | number | |
dateDiff(from, to, unit?) | number | |
addDays(date, days) | date | |
addDuration(date, duration) | date |
Party and witness predicates: partyCount(role), signedCount(role), allSigned(role), anySigned(role), partyType(role), witnessCount(), allWitnessesSigned(), anyWitnessSigned(). The signing predicates read the instance's signing records: a party has signed once its signature is captured, and a witness once it has attested. Before signing they report nobody signed.
visible: "isNotEmpty(fields.notes)"
required: "yearsBetween(fields.birthDate, today()) >= 18"
visible: "partyCount('cosigner') > 0"To replace the old indexOf(...) >= 0 idiom, use contains(...).
Aggregating Lists
A list field holds rows. Seven functions compute one value from them:
| Function | Values | Returns | No rows |
|---|---|---|---|
sum(path, filter?) | number, percentage, money | the same type | 0 |
count(path, filter?) | any row | number | 0 |
avg(path, filter?) | number, percentage, money | the same type | null |
min(path, filter?) / max(path, filter?) | number, percentage, money, date, datetime, time | the same type | null |
any(path, filter?) | boolean | boolean | false |
all(path, filter?) | boolean | boolean | true |
The first argument is a path into a list. fields.lineItems.amount reads the amount of every row, and fields.lineItems on its own is the rows, which is what count usually takes. The second argument is an optional filter: a boolean expression over the same rows, evaluated once per row, so fields.lineItems.taxable in the filter means that row's taxable.
const invoice = p.form()
.name("invoice")
.fields({
currency: { type: "text" },
lineItems: {
type: "list",
item: {
type: "fieldset",
fields: {
description: { type: "text" },
amount: { type: "money" },
taxable: { type: "boolean" },
},
},
},
})
.defs({
subtotal: {
type: "money",
value: { amount: "sum(fields.lineItems.amount).amount", currency: "fields.currency" },
},
taxableTotal: { type: "number", value: "sum(fields.lineItems.amount.amount, fields.lineItems.taxable)" },
rowCount: { type: "number", value: "count(fields.lineItems)" },
everyRowPriced: { type: "boolean", value: "count(fields.lineItems, fields.lineItems.amount.amount <= 0) == 0" },
})
.build()
const draft = invoice.fill({
fields: {
currency: "USD",
lineItems: [
{ description: "Design", amount: { amount: 1200, currency: "USD" }, taxable: true },
{ description: "Hosting", amount: { amount: 99.5, currency: "USD" }, taxable: false },
],
},
})
draft.getFillState().defsValues
// { subtotal: { amount: 1299.5, currency: "USD" }, taxableTotal: 1200, rowCount: 2, everyRowPriced: true }Aggregates work anywhere an expression does: in defs, in visible and required, and in validation rules.
A path through nested lists collects values from every level: sum(fields.orders.parts.cost) totals every part of every order. A filter can test either level, and each test reads the row at its own depth: sum(fields.orders.parts.cost, fields.orders.rush) totals the parts of rush orders. Inside a list item, a row reference aggregates the current row's own list: required: "sum(item.parts.cost) > 1000" on an order's field reads that order's parts only.
The rules are fixed:
- Hidden rows never count. A row is hidden when its list, a field above the list, or the list's
itemis not visible. Anitemcondition that reads a missing input hides the row, as it does in the form state. Showing the row again brings it back on the next evaluation. - Absent values are skipped.
sum,avg,min, andmaxignore anullvalue;countstill counts the row.alltreats an absent boolean as not true. - Money keeps its currency. A sum of money is money in the same currency, with exact decimals. Values in more than one currency fail evaluation with an error naming the currencies found; nothing is converted. A money sum over no rows is the number
0, since there is no currency to give it. minandmaxkeep their other form. Given anything other than a path into a list, they compare their arguments as before.
The checker enforces the shape. An aggregate over the wrong type (sum of a text value), a filter that is not boolean, a path that is not a list, and a filter that reads a different list are authoring errors. So is a path into a list used outside an aggregate: fields.lineItems.amount > 0 reads every row, so the checker asks for sum(...), any(...), or count(...) instead. There are no lambdas, and no map, filter, or reduce.
Types
Field references carry their type into the expression, and the checker enforces it.
- Exact decimals. Numbers and money are exact:
0.1 + 0.2 == 0.3holds, so amount math is reliable. - Temporal types. Dates use
YYYY-MM-DD. Datetimes representing instants requireZor an explicit offset. Month and year addition clamps to the last valid day of the target month.today()andnow()use the artifact's retained clock. - Precision boundaries. Dependent definitions retain decimal precision internally. Values exposed to JavaScript callers are ordinary numbers and inherit JavaScript's precision limits.
- Null-safe access. A missing or unfilled reference is
nullrather than an error.nullis a first-class value you can test for, and member access onnullstaysnullinstead of throwing. An operation that needs a value, such asnull * 2, makes the whole expression missing rather than failed; see Missing and failed values.
Authoring-Time Checking
validate() and fill() run the expression checker before evaluation. They catch:
- unknown references (
fields.hasVehicle.value— there is no.value) - row references (
item,parent) used where no list row encloses the expression - unknown functions
- type mismatches in ordering comparisons (for example, a string field greater than a number)
- gates that don't evaluate to a boolean
- misused list aggregates, and list values read outside one
// Flagged at authoring time: "Unknown variable: fields.hasVehicle.value"
visible: "fields.hasVehicle.value"Named Expressions
When the same logic appears in multiple places, define it once in the defs section and reference it by name:
const form = p.form()
.name("application")
.fields({
age: { type: "number" },
drivingLicense: { type: "text", visible: "isAdult", required: "isAdult" },
parentConsent: { type: "boolean", visible: "not isAdult", required: "not isAdult" },
})
.defs({
isAdult: { type: "boolean", value: "fields.age >= 18" },
})
.build()A def's value is an expression over the same fields.<id> references. Named expressions reduce duplication and create a single source of truth for business rules.
Missing and failed values
A computed value is missing while an input it reads has no value: an unanswered field, a null, or another computed value that is itself missing. A missing value is null, and it is not an error, so a form with totals can be filled from its first answer without guarding each def:
.defs({
subtotal: { type: "money", value: { amount: "sum(fields.lineItems.amount).amount", currency: "fields.currency" } },
tax: { type: "money", value: { amount: "subtotal.amount * fields.taxRatePercent / 100", currency: "fields.currency" } },
})
// Before the rate is answered, tax.amount is null. Once it is, tax is computed.Whatever reads a missing value treats it the way it treats an unanswered field. A visible or required condition that cannot be computed yet is false, so the field waits on the answers it depends on. A rule that cannot be computed yet is not met, and fails with its own message.
A computed value fails when the operation that errors did not read a missing value: a type mismatch, a division by zero, or a money sum in more than one currency. A missing value elsewhere in the expression does not hide the failure. The failure is reported with the def's name and reads as missing, so the rest of the form still evaluates: other defs, conditions, and the fill state's next field. It blocks completion until the inputs change. An expression that handles null itself, with coalesce or ?:, is evaluated as written.
Logic in Annexes
Annexes support the same logic properties as fields:
const form = p.form()
.name("lease-application")
.fields({
hasPets: { type: "boolean", label: "Do you have pets?" },
})
.annexes({
petPhoto: p.annex()
.title("Pet Photo")
.visible("fields.hasPets")
.required("fields.hasPets"),
})
.build()Templates
Layer templates use this language inside their block markers:
{{#if fields.monthlyRent.amount > 2000}}A deposit of {{fields.monthlyRent.amount * 2}} applies.{{/if}}
{{#each parties.tenant}}
- {{index(item) + 1}}. {{item.name}}{{#unless last(item)}},{{/unless}}
{{/each}}
Total: {{sum(fields.lines.amount)}}- A block condition must evaluate to a boolean. A loop source must be a list.
- A placeholder accepts any expression. A path prints the value formatted for its type; a computed value is formatted by its result type.
- Inside
{{#each}},itemis the current row andparentthe enclosing row. A DOCX{{FOR line IN ...}}loop names its row. index(row),first(row), andlast(row)give a row's position, and exist only inside a loop.- A template reads what field logic reads:
fields, computed values by name,parties, and, in a checklist,items. It can call the functions the host has configured, and no others. validate()checks template expressions in inline layers, andvalidateLayers()also reads file layers through a resolver. Errors name the layer, the line and column (or the DOCX paragraph), and the expression.
See Text layers for the markers and the syntax they replace.
Design Time vs Runtime
Logic expressions are defined at design time but evaluated at runtime. The form definition doesn't change—the same logic produces different results based on the data:
const filled1 = form.fill({ fields: { age: 15, drivingLicense: '', parentConsent: true } })
// drivingLicense is hidden, parentConsent is visible
const filled2 = form.fill({ fields: { age: 21, drivingLicense: 'A-12345', parentConsent: false } })
// drivingLicense is visible, parentConsent is hidden