Paradoc
Concepts

Logic

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. The same typed grammar drives both runtime evaluation and authoring-time checking, so an expression that type-checks cannot disagree with how it runs.


Where Logic Applies

Logic expressions can control:

  • visible — whether a field or annex appears
  • required — whether a field or annex must be filled
  • readonly — whether a field can be edited
const form = para.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.


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).


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:

FunctionReturnsNotes
contains(haystack, needle)booleansubstring / membership test
startsWith(value, prefix)boolean
endsWith(value, suffix)boolean
matches(value, pattern)booleanregular-expression match
trim(value)string
lower(value) / upper(value)string
length(value)number
isEmpty(value) / isNotEmpty(value)boolean
coalesce(a, b, …)first non-null

Numbers (decimal-aware):

FunctionReturns
round(value, digits?)number
floor(value) / ceil(value) / abs(value)number
min(a, b, …) / max(a, b, …)number

Dates:

FunctionReturnsNotes
today() / now()date / datetimethe 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().

visible: "isNotEmpty(fields.notes)"
required: "yearsBetween(fields.birthDate, today()) >= 18"
visible: "partyCount('cosigner') > 0"

To replace the old indexOf(...) >= 0 idiom, use contains(...).


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.3 holds, so amount math is reliable.
  • Temporal types. date, time, datetime, and duration are first-class. today() and now() provide the current values; addDays / addDuration / dateDiff / yearsBetween operate on them.
  • Null-safe access. A missing or unfilled reference is null rather than an error. null is a first-class value you can test for, and member access on null stays null instead of throwing.

Authoring-Time Checking

Because evaluation and checking share one type system, mistakes surface when the artifact is authored, not when a user fills it. Building or validating a form runs the checker over every expression, which catches:

  • unknown references (fields.hasVehicle.value — there is no .value)
  • unknown functions
  • type mismatches (comparing a string field to a number)
  • gates that don't evaluate to a boolean
// 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 = para.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.


Logic in Annexes

Annexes support the same logic properties as fields:

const form = para.form()
  .name("lease-application")
  .fields({
    hasPets: { type: "boolean", label: "Do you have pets?" },
  })
  .annexes({
    petPhoto: para.annex()
      .title("Pet Photo")
      .visible("fields.hasPets")
      .required("fields.hasPets"),
  })
  .build()

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

On this page