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
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
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
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:
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.
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
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
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:
validate {
custom age >= 18, t("errors.too.young", { age: age })
}
Conditionals
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
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
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
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:
| Entity | Kind | Render intent | Label | Detail |
|---|---|---|---|---|
applicant | group | RenderGroup | — | |
first_name | field | RenderTextInput | applicant.first (text key) | |
last_name | field | RenderTextInput | applicant.last (text key) | |
annual_income | field | RenderNumberInput | applicant.income (text key) | |
loan_amount | field | RenderNumberInput | applicant.amount (text key) | |
term | field | RenderNumberInput | applicant.term (text key) | |
monthly | field | RenderNumberInput | applicant.monthly (text key) | |
guarantor | group | RenderGroup | — | |
guarantor_name | field | RenderTextInput | applicant.guarantor (text key) | |
guarantor_consent | field | RenderToggle | applicant.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-….