FS FormScript

The language

This is the whole language, in the order the specification introduces it. Each section links to the statement that binds it.

A document

forms
form loan_application version = "1.0" {
  field annual_income { type = number }
}

A document has one form block and may have namespace blocks, text blocks, import and include directives. The version is mandatory: version = "1.0" declares the target, and a runtime that does not implement that MAJOR version rejects the document with E-VER-01 rather than guessing.

Fields and types

forms
field name      { type = string }
field age       { type = integer }
field balance   { type = number }
field active    { type = boolean }
field start     { type = date }
field duration  { type = duration }
field status    { type = enum("employed" = "Employed", "self" = "Self-employed") }
field tags      { type = array of string }
field address   { type = object { street: string, city: string } }

Ten types, and no implicit conversions beyond the four that §5.3 names explicitly. There is no "truthy" coercion and no silent string-to-number conversion: age + "x" is a type error, not a surprise.

integer is not number. An integer is a signed 64-bit whole number; a number is a decimal128. 1 + 1 is an integer; 1 + 1.0 is a number, and the result types differ even when the values look alike. That distinction is observable in the §18.3 conformance rows.

enum carries its members. type = enum("a" = "A", "b" = "B") is a closed set of two values with their labels. The ident_option syntax allows a key with no label at all.

Values, defaults and overrides

forms
field greeting {
  type = string
  value = "Hello, " + name
}

field country {
  type = string
  default = "United Kingdom"
}

field notes {
  type = string
  overridable = true
  value = "computed"
}

A value is a derived field: it recomputes when anything it reads changes. A default is used until the field is answered. A value and a default together is E-CONFLICT-01 — they say contradictory things about the same field.

Expressions

Expressions are total. Every one returns a value; none raises. The results that are not the obvious ones:

forms
1 / 0                  // the error value
"abc" + 1              // the error value
substring("hello", -1) // null

Absence is not zero. A field that has never been answered holds unknown; a field whose entity is not active holds inapplicable. Both are absences, and both are distinct from null.

Logic is three-valued. and, or and ! follow Kleene's strong three-valued logic, extended so that unknown and inapplicable are the undetermined value and the error value is absorbed by the dominant value:

a b a and b
false anything false
true true true
true unknown unknown
unknown unknown unknown

An and whose result is false is false even when the other operand was an error — a false dominates an error, and an error dominates an absence.

Absence propagates through strict operations. + is strict, so unknown + 1 is unknown rather than zero. §6.4 names the builtins that are absence-aware — isEmpty, isUnknown, isInapplicable, isAbsent, isKnown, typeOf, coalesce, ifNull, iif, orDefault.

forms
coalesce(name, "there")   // the first argument that is neither absent nor empty
ifNull(name, "unknown")   // b if a is absent or empty, else a
iif(age >= 18, "adult", "minor")

The builtin library has 67 members. toString, toNumber, toBoolean, toDate, typeOf, isEmpty, the four absence tests, len, graphemeLen, lower, upper, trim, contains, startsWith, endsWith, replace, substring, indexOf, split, join, matches, slugify, abs, ceil, floor, round, min, max, avg, clamp, pmt, now, today, dateAdd, dateDiff, year, month, day, weekday, isBefore, isAfter, age, first, last, sort, unique, flatten, take, skip, minOf, maxOf, count, sum, filledFields, isActive, shadow, isOverridden, t, formatDate, formatMoney, coalesce, ifNull, iif, orDefault.

Every one of them is implemented and the list is checked against §6.4 at build time, so a builtin added to the specification without an implementation turns the build red.

Comprehensions

forms
sum(item in cart, item.price * item.qty)
any(loan in loans, loan.overdue)
all(member in household.members, member.age >= 0)
map(m in members, m.age)
reduce(total in cart, 0, total + $this.price)

$this is the element currently bound. The bound identifier is scoped to the body. A comprehension over an inapplicable collection yields unknown rather than an empty result — the question was not asked.

Constraints

forms
field age {
  type = integer
  validate {
    min = 0
    max = 130
    required
    matches("^[0-9]+$")
  }
}

The constraint kinds are required, min, max, minLength, maxLength, pattern, email, phone, unique, custom and between. A custom constraint carries its own message, and it may use a display value — a value that exists only to be shown to a person:

forms
validate {
  custom age >= 18, t("errors.too.young", { age: age })
}

Conditionals

forms
field guarantor_name {
  type = string
  when = loan_amount > 10000
}

when makes an entity applicable. An inapplicable entity is not rendered, is absent from the output document, and does not block submission. The comparison is three-valued: if the condition is unknown, the entity is inapplicable — the answer is not yet known, so the question is not asked.

Groups, repeats and choices

forms
group address {
  field street { type = string }
  field city   { type = string }
}

repeat dependants {
  max = 5
  field name { type = string }
  field age  { type = integer }
}

choice employment {
  type = enum("employed" = "Employed", "self" = "Self-employed")
  inline = true
}

A repeat must declare max — it is the iteration bound the cost model uses. A choice declares an enum type and nothing else: options and optionsFrom on a choice are E-PROP-02, because a closed option set is already written down in the enum. A form that needs a remote option set declares a field with optionsFrom instead.

Internationalization

forms
text {
  "form.title": "Loan application"
  "loan.monthly": { "default": "Monthly payment", "ar": "القسط الشهري" }
}

field monthly {
  type = number
  label = t("loan.monthly")
}

Text lives in a text block, never inline in a label. §12 says so with a warning rather than an error — W-TEXT-01 — because it is a habit worth breaking early.

Output

forms
output {
  applicant: object { name: string, age: integer }
  monthly: number
}

The output block is a schema, not a projection: each entry names a path and the type of the value at that path. The engine projects the data plane onto it. An entity that is inapplicable is absent from the output document — not null, absent — and a field that is applicable but empty is an explicit null.

What a renderer receives

FormScript emits intents, never markup. §15.2 defines the intent kinds, and a renderer maps them to platform widgets. The reference implementation computes them, and the page below is the real output of the real code for the document above:

EntityKindRender intentLabelDetail
applicantgroupRenderGroup—
first_namefieldRenderTextInputapplicant.first (text key)
last_namefieldRenderTextInputapplicant.last (text key)
annual_incomefieldRenderNumberInputapplicant.income (text key)
loan_amountfieldRenderNumberInputapplicant.amount (text key)
termfieldRenderNumberInputapplicant.term (text key)
monthlyfieldRenderNumberInputapplicant.monthly (text key)
guarantorgroupRenderGroup—
guarantor_namefieldRenderTextInputapplicant.guarantor (text key)
guarantor_consentfieldRenderToggleapplicant.consent (text key)

The error catalogue

Every diagnostic has a stable code and a fixed severity, so a tool can be written against them. There are 78, listed by formscript errors and in the specification reference.

The codes are grouped: E-LEX (lexical), E-SYN (syntactic), E-SEM (semantic), E-TYPE (type), E-BOUND (boundaries), E-REF (references), E-CONFLICT (contradictions), E-BLOCK (block structure), E-VAL (values), E-REL (relations), E-CYCLE (cycles), E-IO (input/output), E-VER (version), E-EXT (extensions), E-EVAL (evaluation), E-STATE (state), E-STATE-01 and the warnings W-….