Skip to content

The template language

Expressions

Values, operators and precedence, calls, pipes and lambdas.

Anywhere a value goes can compute it with an expression: attribute values, {…} in strings, if conditions, for … in, and the right-hand sides of declarations.

Expressions are pure. They read names and return values, and never change anything. An expression that reads state is evaluated again when the state changes.

Values

There are six kinds of value:

KindWrittenNotes
nullnullno value
booltrue, false
number3, -2.564-bit floating point
text"Hello {name}"with interpolation
list[1, 2, 3]items of any kind
record{ title: "A", done: false }named fields, kept in name order
  • { title } is short for { title: title }.
  • A trailing comma is allowed in lists, records and calls.
  • type_of(x) names a value's kind: "null", "bool", "number", "text", "list" or "record".

Values as text

Where a value is shown as text, in interpolation or an attribute:

ValueText
nullnothing
true, falsetrue, false
a numberthe shortest form: 3, 2.5, 0.30000000000000004
textitself
a listits items as text, joined with ,
a record[record]

For more control, use format_number, percent, join and round (Standard functions).

Truthiness

Conditions (if, &&, ||, !, when) read a value as true or false: null, false, 0, empty text and an empty list are false, and everything else is true, including every record.

Names and fields

  • A name reads the scope: a declaration, a prop, a companion's value, or a loop variable.
  • A path, post.author.name, reads a field, then its field. On a list, .len is its length.
  • A field of a computed value is written after ), ] or }: first(posts).title, { a: 1 }.a.

Reading a name the scope doesn't have, or a field a record doesn't have, is an error. The exception is .field after ), ] or }, which gives null for a missing field. Check first with has(record, "field"), or read with get(record, "field"), which gives null.

Operators

OperatorDoes
a + badds numbers; joins two lists; joins text when either side is text (the other side as text). A list on the left needs a list on the right
a - b, a * barithmetic on numbers
a / b, a % bdivision and remainder; null when b is zero
-anegation
a == b, a != bequality (see below)
a < b, <=, >, >=order (see below)
a && b, a || blogic; the result is true or false, and the right side is only evaluated when needed
!anot
a ?? ba, or b when a is null
if c then a else ba when c is truthy, otherwise b
x |> f(y)the call f(x, y)
{ r | a: 1 }a copy of record r with field a set

Arithmetic needs numbers: text that reads as a number ("42") counts as one, and anything else is an error, such as "a" * 2.

Comparison:

  • When both sides read as numbers (numbers, or text such as "10"), they're compared as numbers: "10" == 10 is true, and "9" < "10" is true.
  • Otherwise both sides are compared as text, so lists compare by their text, and any two records are equal. Compare their fields instead.
  • null is equal only to null.

Precedence

From loosest to tightest. Every binary operator groups from the left.

LevelOperators
1if … then … else
2??
3||
4&&
5== != < <= > >=
6|>
7+ -
8* / %
9! and - (prefix)
10calls, fields

So:

  • a ?? b + 1 is a ?? (b + 1).
  • xs |> len > 2 compares the length: a pipe binds tighter than a comparison, as in Elixir.
  • !a == b is rejected, since it's easy to misread: write a != b, or (!a) == b.
  • a < b < c compares a < b, a boolean, with c. Write a < b && b < c.
  • An if extends as far right as it can, so put one inside other operators in parentheses: 1 + (if big then 10 else 0).

Calls and pipes

A function is called with parentheses, f(x, y), never f x: attributes are separated by spaces, so juxtaposition would be ambiguous.

A pipe passes the value on its left as a call's first argument, so a chain reads left to right:

let shown = posts |> filter(p -> p.published) |> sort_by(p -> p.date) |> reverse |> take(5)
for p in shown { p "{p.title}" }

A pipe into a function with no other arguments needs no parentheses: name |> trim |> lower.

The functions are the standard functions and any the scope adds with Scope::function, which win over a standard function of the same name. Calls to standard functions are checked when the template is parsed: the number of arguments, and where lambdas go.

Lambdas

A lambda is a function written inline, only as a function's argument:

let done = todos |> filter(t -> t.done)
let labels = todos |> map((t, i) -> text(i + 1) + ". " + t.title)
p "{done.len} done: {join(labels)}"
  • t -> expr takes one parameter, and (a, b) -> expr takes two.
  • The lambdas of map, filter, find, find_index, any, all and count may take a second parameter: the item's index, from 0.
  • A lambda reads the names around it, and its parameters hide them.

Records and lists, updated

Values are never changed in place. A step that "changes" a list or record builds a new one:

ToWrite
change a record's field{ t | done: !t.done }
add to a listtodos + [item]
change item itodos |> update_at(i, t -> { t | done: true })
replace item itodos |> set_at(i, value)
insert before item itodos |> insert_at(i, value)
remove item itodos |> remove_at(i)
remove by a conditiontodos |> filter(t -> t.id != id)

Updating null with { r | a: 1 } gives a new record { a: 1 }.

List comprehensions

[expression | generators and conditions] builds a list, read left to right:

let open = [t.title | t <- todos, !t.done]
let pairs = [[x, y] | x <- xs, y <- ys, x != y]
ul { for title in open { li "{title}" } }
  • name <- list goes through a list, with name as each item for everything after it. Later generators nest inside earlier ones, so pairs takes every y for each x.
  • A condition keeps only the items for which it holds.
  • A list that's null (not loaded yet) gives nothing to go through, so the result is [].

It's a shorter way to write filter then map: [t.title | t <- todos, !t.done] is todos |> filter(t -> !t.done) |> map(t -> t.title).

To add one item, put it in brackets: todos + [item]. Without them, todos + item is an error: + only adds a list to a list.