FS FormScript

Lists and comprehensions

A field can hold a list, and a list is where most of the interesting expressions live.

forms
form basket version = "1.0" {
  field items { type = array(0..20) of string }
}

The comprehensions

Five of them, and they all read the same way: what to compute, over what, for which element.

forms
form basket version = "1.0" {
  field items    { type = array(0..20) of string }
  field quantity { type = integer }
  field total    { type = number value = sum(item in items, toNumber(item) * quantity) }
}
The form What it gives you
sum(x in list, expr) the total
any(x in list, expr) is it true for at least one?
all(x in list, expr) is it true for all of them?
map(x in list, expr) a new list
reduce(x in list, start, expr) fold the list into one value

sum, any and all also work over a group or a repeat:

forms-fragment
field overdue { type = boolean value = any(loan in loans, loan.overdue) }
field adults  { type = boolean value = all(member in household.members, member.age >= 18) }

The bound name

The name between in and the comma is bound for the body of the expression and only there:

forms-fragment
field names { type = array of string value = map(m in members, m.name) }

Inside the body, m is the current element. Outside it, m means nothing — a comprehension does not leak its name.

$this and position()

Inside a comprehension, the bound identifier is the element being looked at — and $this is another name for it. You can use either.

forms-fragment
field names { type = array(0..5) of string value = map(row in dependants, $this.name) }

position() is the other half of the story, and it is the one you need inside a repeat — a list of rows the person fills in:

forms
form household version = "1.0" {
  repeat dependants {
    max = 5
    field name { type = string }
    field age  { type = integer }

    field row_number { type = integer  value = position() }
  }
}
  • $this is the element currently being looked at.
  • position() is its 1-based position in the repeat.

So the first row's row_number is 1, the second 2, the third 3. A language that can repeat a group but cannot number its elements cannot carry a household roster — every row would be the same row.

position() outside a repeat has no current element, and the specification says what that means: the error value.

reduce — folding a list

forms-fragment
field running_total { type = number value = reduce(item in items, 0, toNumber(item)) }

reduce takes the list, a starting value, and an expression that gives each element's contribution. The contributions are added together, starting from the initial value. It is the one comprehension with three arguments.

The list builtins

Not everything needs a comprehension. These work on a list directly:

The call What it does
count(list) how many
first(list) / last(list) the ends
sort(list) in order
unique(list) without the duplicates
flatten(list) one level deeper
take(list, n) / skip(list, n) a part of the list
minOf(list) / maxOf(list) the smallest or largest
sum(list) the total
avg(list) the mean
forms-fragment
field total     { type = number value = sum(amounts) }
field cheapest  { type = number value = minOf(prices) }
field first_name{ type = string value = first(names) }

An iterated list must have a bound

Every collection that a comprehension walks over has to declare how long it can be. A repeat declares it with max; an array declares it in its type:

forms
form basket version = "1.0" {
  field items { type = array(0..20) of string }
  field total { type = number value = sum(item in items, toNumber(item)) }
}

An array with no bound — type = array of string — is E-COST-01 when something walks over it: "the expression iterates an unbounded collection". The runtime has to be able to say how much work a form is before anybody fills it in, and it cannot do that for a list that could be any length.

The same rule is why a repeat must declare max, and why the cost model can bound a whole form.

Lists of objects

A list can hold objects, and then the members are reached with a path:

forms
form basket version = "1.0" {
  field lines { type = array(0..20) of object { sku: string, quantity: integer, price: number } }

  field total { type = number value = sum(line in lines, line.price * line.quantity) }
  field skus  { type = array of string value = map(line in lines, line.sku) }
}

line.price is a dotted path that names the member price of the element line. The rule about dotted paths is in section 5.

An empty list is not an absent one

A comprehension over an inapplicable collection yields unknown rather than an empty result. The question was not asked, so the answer is not "none".

That matters when you write sum(item in items, …): if items does not apply — a repeat the person has not started, or a group whose condition is false — then total is unknown, not 0. The form will say "unknown" rather than claim that the basket is empty.

An empty list is a different thing: the person answered, and the list has nothing in it. sum of an empty list is 0, and avg of an empty list is the error value.

A worked example

forms
form basket version = "1.0" {
  field lines { type = array(0..20) of object { sku: string, quantity: integer, price: number } }

  field item_count { type = integer value = count(lines) }
  field total      { type = number  value = sum(line in lines, line.price * line.quantity) }
  field average    { type = number  value = avg(map(line in lines, line.price)) }
  field in_stock   { type = boolean value = all(line in lines, line.quantity > 0) }
  field skus       { type = array of string value = map(line in lines, line.sku) }
  field expensive  { type = boolean value = any(line in lines, line.price > 100) }

  output {
    item_count: integer
    total: number
    average: number
    in_stock: boolean
    skus: array of string
    expensive: boolean
  }
}

Read the value lines one at a time: each is one sentence about the lines, written as an expression.