The WCL language · reference

Functions

15 min read · 2026-11-01 · wcl 0.33.2-alpha

A function in WCL is a value, the same way a number or a list is a value. You write one with fn, bind it to a name or hand it straight to another function, and call it with parentheses. There is one calling convention and it is deliberately small: one parameter list, filled left to right, every time. No default arguments, no named arguments, no overloading.

This chapter covers the literal, the fn item that names one, the type that describes one, and the builtins that call one. It also covers what a function can see while it runs. It ends with the file the whole chapter builds towards.

Every file and every command below was run before it was written down. Type them and compare.

A function is a value

The literal is fn(params) -> ReturnType body. Each parameter is name: Type. The return type follows ->. The body is one expression:

wcl
let double = fn(x: i64) -> i64 x * 2i64

Nothing on that line is a declaration. fn(x: i64) -> i64 x * 2i64 is an expression that evaluates to a function value, exactly as [1, 2] evaluates to a list. The let is the ordinary let item, and it does nothing special.

The body may also be a { … } block expression: local bindings, then one final expression that is the value. Note the semicolon: the expression-level let inside braces takes one, and the let item outside does not. Expressions and operators covers both.

arith.wclwcl
@document
type Cfg {
  four: i64
  nine: i64
}

let double = fn(x: i64) -> i64 x * 2i64
let sum_sq = fn(x: i64, y: i64) -> i64 { let s = x + y; s * s }

four = double(2i64)
nine = sum_sq(1i64, 2i64)
console
$ wcl check arith.wcl
OK
$ wcl get arith.wcl four
4
$ wcl get arith.wcl nine
9

A parameter list may be empty, and it may carry one trailing comma:

wcl
let answer = fn() -> i64 42i64
let add    = fn(x: i64, y: i64,) -> i64 x + y

Both annotations are mandatory

WCL infers neither end of a signature. fn(x) -> i64 x fails with expected ':' after parameter name, found ')', and fn(x: i64) x fails with expected '->' before return type. Every parameter carries a type and every function states its return type, in a literal and in an item alike.

fn items

Write fn before a name, at file or block scope, to bind the same literal to that name:

wcl
fn add(x: i64, y: i64) -> i64 x + y

# The same binding, spelled the other way.
let add2 = fn(x: i64, y: i64) -> i64 x + y

The two lines are one item form. Documents, fields and blocks counts thirteen item forms and fourteen spellings for exactly this reason. A fn item is a let item: the same node, the same resolution path.

let items as composition helpers

So everything let items says applies to a fn item unchanged, and the part that matters most is what a let is for. A let binds a name that sibling and descendant expressions may read, and nothing else can see at all. It helps you write the data. It is not data. Reach for it when only other expressions need the value. Reach for a field when a consumer of the document is meant to read it.

A function is the purest case of that. It means nothing to a consumer reading the file back. So the natural place to put one is a binding that leaves no trace. Both spellings are invisible to wcl get, to wcl parse output, to a JSON serialization, and to schema validation:

calc.wclwcl
@document
type Cfg { total: i64 }

fn add(x: i64, y: i64) -> i64 x + y

total = add(10i64, 20i64)
console
$ wcl get calc.wcl total
30
$ wcl get calc.wcl add
no such path: add

The one function that is data is the one a field holds, and that is a function type, further down.

What a fn item adds

A fn item adds exactly two things over let name = fn(…).

It is indexed. The parser deliberately keeps a plain let out of the symbol index. That is what stops it leaking into a document's field list, or into wcl get. A fn item opts back in, as a function declaration. The language server reads that index. A fn item, and only a fn item, gets an outline entry, a hover card, and go-to-definition. See The CLI for wcl lsp.

The index has a second, sharper effect: it catches a duplicate name. Two fn items sharing a name are a parse error. Two let bindings sharing a name are not, and the first one silently wins.

console
$ wcl check dup-fn.wcl
wcl::parse

  × duplicate declaration 'f'
   ╭─[dup-fn.wcl:3:1]
 2 │ type Cfg { a: i64 }
 3 │ fn f(x: i64) -> i64 1i64
   · ────────────┬───────────
   ·             ╰── first declared here
 4 │ fn f(x: i64) -> i64 2i64
   · ────────────┬───────────
   ·             ╰── duplicate declaration
 5 │ a = f(1i64)
   ╰────

$ wcl check dup-let.wcl
OK
$ wcl get dup-let.wcl a
1

It takes decorators. A let refuses them outright. A fn item accepts them, and the decorator system knows fn as a position of its own, alongside field, block, and type.

wcl
@doc("Trim a label and lower-case it.")
fn tidy(s: utf8) -> utf8 to_lower(trim(s))
console
$ wcl check decorated-let.wcl
wcl::parse

  × decorators are not allowed on let bindings
   ╭─[decorated-let.wcl:3:1]
 2 │ type Cfg { a: i64 }
 3 │ @doc("x")
   · ────┬────
   ·     ╰── remove decorator
 4 │ let f = fn(x: i64) -> i64 x
   ╰────

Decorators covers what you may write there. So reach for fn name(…) for anything you want to find again. Reach for let name = fn(…) when the function is a one-off you need twice.

Where a fn item may sit

Anywhere a let item may sit: the top level of a file, or inside a block. Inside a block, the item's scope is that block and its descendants:

ports.wclwcl
@block("service")
type Service {
  @inline(0) name: identifier
  port:  u32
  admin: u32
}

@document
type Cfg {
  @children("service") services: list<Service>
}

service web {
  fn bump(p: u32) -> u32 p + 1u32

  port  = 8080u32
  admin = bump(self.port)
}
console
$ wcl get ports.wcl services.web.admin
8081u32
$ wcl get ports.wcl services.web.bump
no such path: services.web.bump
did you mean: service?

Recursion needs nothing special. A name at document scope resolves when the call runs, not when you write the body, so a function may name itself:

fact.wclwcl
@document
type Cfg { a: i64 }

fn fact(n: i64) -> i64 if n <= 1i64 { 1i64 } else { n * fact(n - 1i64) }

a = fact(5i64)
console
$ wcl get fact.wcl a
120

That laziness is the same one How a document evaluates describes for fields, and it is why item order never matters. There is a ceiling. Evaluation nests at most 200 deep, and a call counts as one level alongside every field and let it forces on the way down. A call past the ceiling stops with call depth limit exceeded (max 200). A fn with no base case therefore fails with an error instead of crashing.

Function types

fn(T1, T2, …) -> R is a type, and it describes a callable. Note how it differs from the literal. A type names the parameter types only, because a parameter's name is no part of a caller's contract.

WrittenIsMeans
fn(x: i64) -> i64 x * 2i64a valuethis function
fn(i64) -> i64a typesome function taking an i64 and returning an i64

Use it wherever a type goes: a field on a block, an element of a list<…>, or a parameter of another function. A field declared that way holds a real function, and you call it straight through the dotted path:

steps.wclwcl
@block("step")
type Step {
  @inline(0) name: identifier
  apply: fn(i64) -> i64
}

@document
type Cfg {
  @children("step") steps: list<Step>
  out: i64
}

step scale {
  apply = fn(x: i64) -> i64 x * 3i64
}

out = steps.scale.apply(7i64)
console
$ wcl check steps.wcl
OK
$ wcl get steps.wcl out
21
$ wcl get steps.wcl steps.scale.apply
fn(x: i64) -> i64 { ... }

The schema checks that field the way it checks any other. Write apply = 3i64 instead and wcl check names it:

console
$ wcl check steps.wcl
wcl::eval::schema_violation

  × field 'apply' declared as fn(i64) -> i64 but value is i64
    ╭─[steps.wcl:14:3]
 13 │ step scale {
 14 │   apply = 3i64
    ·   ──────┬─────
    ·         ╰── schema violation
 15 │ }
    ╰────

steps.wcl: 1 schema violation

A function value is not serializable data

The check above is a shape check, and it is where the guarantees stop. A function value has no JSON form: wcl get --json steps.wcl steps.scale.apply prints null, not an error. Declare a fn field when a host, or a later expression in the same document, will call it. Do not declare one on data you expect to round-trip through JSON.

Higher-order functions

A function that takes or returns another function is higher-order. WCL needs nothing extra for it: a function is a value, so it passes and returns like one. That is how the collection builtins take their behaviour from you.

hof.wclwcl
@document
type Cfg {
  doubled: list<i64>
  evens:   list<i64>
  total:   i64
}

doubled = map([1i64, 2i64, 3i64], fn(x: i64) -> i64 x * 2i64)
evens   = filter([1i64, 2i64, 3i64, 4i64], fn(x: i64) -> bool x % 2i64 == 0i64)
total   = fold([1i64, 2i64, 3i64], 0i64, fn(acc: i64, x: i64) -> i64 acc + x)
console
$ wcl get hof.wcl doubled
[2, 4, 6]
$ wcl get hof.wcl evens
[2, 4]
$ wcl get hof.wcl total
6

Eleven builtins call the function you hand them. Learn these by heart:

BuiltinThe function it takesGives back
map(xs, f)fn(T) -> U[U], every element transformed
filter(xs, pred)fn(T) -> bool[T], the elements that passed
fold(xs, init, f)fn(U, T) -> UU, one accumulated value
any(xs, pred)fn(T) -> boolbool, true for at least one (short-circuits)
all(xs, pred)fn(T) -> boolbool, true for every one (true when empty)
find(xs, pred)fn(T) -> boolthe first match, or none
sort_by(xs, key)fn(T) -> K[T], ascending by key, stable
min_by(xs, key)fn(T) -> Kthe element with the smallest key, or none
max_by(xs, key)fn(T) -> Kthe element with the largest key, or none
group_by(xs, key)fn(T) -> K[{ key, items }], in first-seen key order
map_values(r, f)fn(T) -> Ua record, same keys, transformed values

Builtins documents each in full, and Lists, tensors and records covers the collections they walk. They compose the way you would expect. A named function goes in by name, with no wrapper and no call parentheses:

words.wclwcl
@document
type Cfg {
  shortest_first: utf8
  slugs:          list<utf8>
}

fn slug(s: utf8) -> utf8 replace(to_lower(s), " ", "-")

shortest_first = join(sort_by(["pear", "fig", "apple"], fn(s: utf8) -> i64 len(s)), ", ")
slugs          = map(["Hello World", "Bye Now"], slug)
console
$ wcl get words.wcl shortest_first
"fig, pear, apple"
$ wcl get words.wcl slugs
["hello-world", "bye-now"]

A builtin is not a value

map(xs, slug) works because slug is a fn item, and a fn item is a binding that holds a function value. A builtin is not a binding: map(["x", "y"], to_upper) fails with unresolved reference 'to_upper'. Builtins are callable by name, not passable by name. Wrap one in a literal to pass it: map(["x", "y"], fn(s: utf8) -> utf8 to_upper(s)).

Returning a function

To curry, give a literal a return type that is itself a function type:

curry.wclwcl
@document
type Cfg { seven: i64 }

let adder = fn(x: i64) -> fn(i64) -> i64 fn(y: i64) -> i64 x + y
let add3  = adder(3i64)

seven = add3(4i64)
console
$ wcl get curry.wcl seven
7

Read that middle line in three parts. fn(x: i64) is the outer parameter list. -> fn(i64) -> i64 is the outer return type. The rest is an inner literal, and it is the outer body. add3 is that inner function with x already fixed at 3. It is a closure, and closures are the next section.

Closures and capture

A function literal captures the local bindings in scope where you write the literal. It captures them by value, at the moment WCL evaluates the literal:

capture.wclwcl
@document
type Cfg { a: i64 }

# `f` captures n = 1. Re-binding `n` afterwards changes nothing.
a = { let n = 1i64; let f = fn(x: i64) -> i64 x + n; let n = 100i64; f(0i64) }
console
$ wcl get capture.wcl a
1

A function literal does not capture names at document scope, and does not need to. A document is immutable once it is open. A let item, a field, or a block therefore resolves by a scope walk at call time. That is why the recursive fact above works, and why a function may read a field declared below it.

Inside a call, names resolve in this order. The last binding of a name wins:

The caller's own local bindings are not on that list. A call starts from the function's captures and parameters alone, so a body cannot read a let that happens to be in force where it is called:

ghost.wclwcl
@document
type Cfg { a: i64 }

fn boo() -> i64 ghost

# The caller's `ghost` is invisible inside `boo`.
a = { let ghost = 1i64; boo() }
console
$ wcl get ghost.wcl a
wcl::eval::unresolved_reference

  × unresolved reference 'ghost'
   ╭─[ghost.wcl:4:17]
 3 │
 4 │ fn boo() -> i64 ghost
   ·                 ──┬──
   ·                   ╰── does not resolve
 5 │
   ╰────

Close over what you use

A free name is one the function neither captured nor took as a parameter. Only document scope can answer it, walked from the block the call sits in. Write functions that use their parameters, their captures, and document-scope names, and hand anything else in as an argument.

No defaults and no named arguments

A parameter list is fixed at declaration. Two things you may reach for do not exist, and each is a parse error rather than a surprise at run time.

There are no default arguments:

console
$ wcl check default.wcl
wcl::parse

  × expected ',' or ')' in parameter list, found '='
   ╭─[default.wcl:3:21]
 2 │ type Cfg { a: utf8 }
 3 │ fn greet(name: utf8 = "world") -> utf8 name
   ·                     ┬
   ·                     ╰── expected ',' or ')'
 4 │ a = greet("x")
   ╰────

And there are no named arguments at the call site. greet(name = "x") fails too, on the = rather than the ::

console
$ wcl check named.wcl
wcl::parse

  × expected ',' or ')' in call arguments, found ':'
   ╭─[named.wcl:4:15]
 3 │ fn greet(name: utf8) -> utf8 name
 4 │ a = greet(name: "x")
   ·               ┬
   ·               ╰── expected ',' or ')'
   ╰────

So every call fills every parameter, positionally, and a call with the wrong number of arguments stops before the body runs. A parameter typed utf8? is no exception. The ? lets the argument be none, as below shows, but the call must still pass one:

console
$ wcl get arity.wcl a
wcl::eval::builtin_arity

  × 'add' expects 2 argument(s), got 1

What a call checks

The count is the first check. Once it passes, the call holds every argument to its parameter's type, runs the body, and holds the result to the return type. The rule is one you already know. An argument is accepted exactly when a field declared with the parameter's type would accept it, and it arrives converted the way that field would convert it. The return value meets the return type the same way. Schemas is where that rule is written out for fields. Nothing about it is special to functions.

calls.wclwcl
@document
type Cfg {
  port:  u16
  label: identifier
  twice: i32
  wrong: u16
  huge:  i32
}

fn port_of(p: u16) -> u16 p
fn label_of(id: identifier) -> identifier id
fn double(x: i32) -> i32 x * 2

port  = port_of(8080)
label = label_of("intro")
twice = double(21)
wrong = port_of("8080")
huge  = double(2000000000)
console
$ wcl get calls.wcl port
8080u16
$ wcl get calls.wcl label
intro
$ wcl get calls.wcl twice
42i32

Each of those three is a conversion a field would make. The literal 8080 is an i64 until the u16 parameter narrows it, because it fits. The string "intro" becomes the identifier intro, which is why it prints without quotes. And x * 2 multiplies an i32 by an untyped i64 literal, which Expressions and operators explains comes out i128. The i32 return type narrows that back to 42i32, again because it fits.

A value no field would take fails the call. The two other fields show both halves:

console
$ wcl get calls.wcl wrong
wcl::eval::argument_type

  × argument 1 ('p') of 'port_of' expects u16, got utf8
    ╭─[calls.wcl:17:9]
 16 │ twice = double(21)
 17 │ wrong = port_of("8080")
    ·         ───────┬───────
    ·                ╰── expects u16
 18 │ huge  = double(2000000000)
    ╰────
$ wcl get calls.wcl huge
wcl::eval::return_type

  × 'double' declared -> i32 but returned 4000000000i128, which is out of
  │ range for i32
    ╭─[calls.wcl:12:26]
 11 │ fn label_of(id: identifier) -> identifier id
 12 │ fn double(x: i32) -> i32 x * 2
    ·                          ──┬──
    ·                            ╰── returns 4000000000i128, which is out of range for i32
 13 │
    ╰────

An argument error points at the argument when the argument is an expression with a position of its own, and at the whole call when it is a literal, as "8080" is here. A return error points at the body. An anonymous literal is named fn literal in both messages. A function you pass by name, such as map(xs, slug), keeps the name its let or fn item gave it.

Both are evaluation errors, the kind a field raises when its expression fails. They surface when something reads the field. So wcl check on its own passes calls.wcl, and --eval forces every field and reports them. A try catches either one, with the message above as the caught value. Control flow covers try.

console
$ wcl check calls.wcl
OK
$ wcl check --eval calls.wcl
wcl::eval::return_type

  × 'double' declared -> i32 but returned 4000000000i128, which is out of
  │ range for i32
    ╭─[calls.wcl:12:26]
 11 │ fn label_of(id: identifier) -> identifier id
 12 │ fn double(x: i32) -> i32 x * 2
    ·                          ──┬──
    ·                            ╰── returns 4000000000i128, which is out of range for i32
 13 │
    ╰────

wcl::eval::argument_type

  × argument 1 ('p') of 'port_of' expects u16, got utf8
    ╭─[calls.wcl:17:9]
 16 │ twice = double(21)
 17 │ wrong = port_of("8080")
    ·         ───────┬───────
    ·                ╰── expects u16
 18 │ huge  = double(2000000000)
    ╰────

calls.wcl: 2 evaluation errors

Because the rule is the field rule, it carries the field rule's edges with it. Three are worth knowing before they surprise you:

Words only a signature may use

A signature may name four value classes that no field type can. They come from the builtin signature notation, and they check without converting anything:

WordAccepts
anyEvery value, none included
numberAny integer or float, of any width
integerAny integer, of any width or signedness
recordA record, or a union variant whose payload is a record

A signature may also name an interface bare, where a field needs &. The argument must then satisfy the interface structurally: a record, or a variant carrying one, needs every field the interface declares, with a matching type. Other values pass, as they do everywhere WCL checks an interface against a value.

words.wclwcl
interface Sized { width: f64 }

@document
type Cfg {
  area:  f64
  count: i64
  bad:   f64
}

fn area_of(s: Sized, h: number) -> f64 s.width * h
fn count_of(xs: list<any>) -> integer len(xs)

area  = area_of({ width: 2.0 }, 3)
count = count_of([1, "two", none])
bad   = area_of({ height: 2.0 }, 3)
console
$ wcl get words.wcl area
6.0
$ wcl get words.wcl count
3
$ wcl get words.wcl bad
wcl::eval::argument_type

  × argument 1 ('s') of 'area_of' expects Sized, got record without field
  │ 'width'
    ╭─[words.wcl:15:17]
 14 │ count = count_of([1, "two", none])
 15 │ bad   = area_of({ height: 2.0 }, 3)
    ·                 ───────┬───────
    ·                        ╰── expects Sized
    ╰────

The error says only record there because nothing declares the literal { height: 2.0 }. A record literal carries no type of its own. When the argument expression reads it out of somewhere that does declare one, such as a typed field, an element of a typed list, a parameter or a function's return type, the error names that type. Here the records sit in a field declared list<Item>:

tags.wclwcl
type Item {
  sku:   utf8
  count: u32
}

@document
type Stock {
  items: list<Item>
  label: utf8
}

fn tag(s: utf8) -> utf8 $"item ${s}"

items = [{ sku: "a-1", count: 3u32 }]
label = tag(at(items, 0))
console
$ wcl get tags.wcl label
wcl::eval::argument_type

  × argument 1 ('s') of 'tag' expects utf8, got record of type 'Item'
    ╭─[tags.wcl:15:13]
 14 │ items = [{ sku: "a-1", count: 3u32 }]
 15 │ label = tag(at(items, 0))
    ·             ──────┬─────
    ·                   ╰── expects utf8
    ╰────

Passing at(items, 0).sku instead gives "item a-1". The same naming applies to the other refusals: an interface miss reads got record of type 'Item' without field 'width', and a body that hands back the record, as in fn first(i: Item) -> utf8 i, fails with 'first' declared -> utf8 but returned record of type 'Item'. The record itself is unchanged; only the message looks back at the declaration. A list is named element by element: hand items to a function declared fn tags(ss: list<utf8>) and the error reads got a list whose element [0] is record of type 'Item'.

A builtin that calls your function names the record the same way. map hands its callback each element of items, so the error names the element type the list declares, even though no call in the file passes the record by hand:

labels.wclwcl
type Item {
  sku:   utf8
  count: u32
}

@document
type Stock {
  items:  list<Item>
  labels: list<utf8>
}

items  = [{ sku: "a-1", count: 3u32 }]
labels = map(items, fn(s: utf8) -> utf8 $"item ${s}")
console
$ wcl get labels.wcl labels
wcl::eval::argument_type

  × argument 1 ('s') of fn literal expects utf8, got record of type 'Item'
    ╭─[labels.wcl:13:10]
 12 │ items  = [{ sku: "a-1", count: 3u32 }]
 13 │ labels = map(items, fn(s: utf8) -> utf8 $"item ${s}")
    ·          ──────────────────────┬─────────────────────
    ·                                ╰── expects utf8
    ╰────

filter, any, all, find, sort_by, min_by, max_by and group_by name their element the same way. fold takes its element from the list too, and its accumulator from the initial value on the first call and from the type the callback declares it returns after that. map_values, which hands its callback each value of a record, still says record.

Two more types check less than they appear to. A parameter typed fn(A) -> R checks that the argument is a function with the same number of parameters. It does not compare their types, exactly as a fn-typed field does not. And a &T parameter wants what a &T field holds, a reference into the document: a declaration named bare, as in f(Service), or the value of another &T field. It does not check what the reference points at. A record value fails it, and a block reached by its path, f(services.web), is a record by the time the call sees it.

A parameter that takes none

A parameter declared utf8 refuses none: greet(none) against fn greet(name: utf8) fails with argument 1 ('name') of 'greet' expects utf8, got none. Add ? to the type and it means what it means on a field. name: utf8? accepts none, and holds every other value to utf8, converting it as a utf8? field would. A return type takes the same mark, so -> utf8? lets the body produce none.

greet.wclwcl
@document
type Cfg {
  plain: utf8
  named: utf8
  wrong: utf8
  head:  utf8?
}

fn greet(name: utf8?) -> utf8 if name == none { "hello" } else { $"hello, ${name}" }
fn first(xs: list<utf8>) -> utf8? if len(xs) == 0 { none } else { at(xs, 0) }

plain = greet(none)
named = greet("Ada")
wrong = greet(42)
head  = first([])
console
$ wcl get greet.wcl plain
"hello"
$ wcl get greet.wcl named
"hello, Ada"
$ wcl get greet.wcl head
none
$ wcl get greet.wcl wrong
wcl::eval::argument_type

  × argument 1 ('name') of 'greet' expects utf8?, got i64
    ╭─[greet.wcl:14:9]
 13 │ named = greet("Ada")
 14 │ wrong = greet(42)
    ·         ────┬────
    ·             ╰── expects utf8?
 15 │ head  = first([])
    ╰────

The ? makes the value optional, not the argument. greet still takes exactly one argument, and greet() fails with 'greet' expects 1 argument(s), got 0. The usual source of a none is an optional field passed straight through, greet(user.nickname), and that is the case T? is for.

The mark goes on the whole type of a parameter or of the return, and nowhere inside one. list<utf8?> does not parse in a signature, just as it does not in a field. Nor does a fn(…) type take it: in fn(utf8?) -> utf8 the ? is a parse error. A trailing ? after a field typed fn(…) -> T already belongs to the field, which it makes optional. any still accepts none too, so keep any for a parameter that takes values of several types, not for one that takes a T or nothing.

Every name must resolve

Before any call runs, every type in a signature has to resolve, exactly as a field's type does, and a name nothing declares stops the file from opening. The check reaches every function literal, including a lambda passed straight into map, and every file the document imports at the top level.

sig.wclwcl
fn f(x: bogus) -> i64 1
console
$ wcl check sig.wcl
wcl::parse

  × unknown type 'bogus'
   ╭─[sig.wcl:1:9]
 1 │ fn f(x: bogus) -> i64 1
   ·         ──┬──
   ·           ╰── type not declared
   ╰────

What a fixed parameter list costs

A fixed parameter list is the one shape a caller can rely on. The wdoc standard library is where that trade-off is most visible. Its el family builds the HTML element vocabulary, and it is three constructors rather than one because a parameter list cannot vary:

wcl
el(tag, cls, kids)           # the common shape
ela(tag, cls, attrs, kids)   # … with attributes
eli(tag, id, cls, kids)      # … with an explicit HTML id

Two consequences follow, and both are worth copying when you write a constructor family of your own. First, an optional value goes in as an ordinary argument, so the parameter that receives it is optional too. That is why cls is list<utf8>? in all three, and id is identifier? in eli: a block's unset class arrives as none. Second, the type check separates two constructors only where their parameter types differ. ela and eli take the same number of arguments, but their middle two slots differ in type. Call ela with eli's arguments and the id string lands in cls, a list<utf8>?. Call eli with ela's arguments and the class list lands in id, an identifier?. Both calls fail, and the error names the argument. Had cls and id been any, the second mix would have passed silently. Documents, pages and sites documents the family. Writing your own blocks is where you will call it.

Four ways to write a function down

The forms above are not interchangeable. All four hold the same kind of value, and they differ in what the rest of the system can see of it.

FormNamedIn the symbol indexTakes decoratorsDocument dataReach for it when
Literal at the call siteNoNoNoNoThe function is an argument and nothing else
let f = fn(…)YesNoNoNoA one-off you happen to need twice
fn f(…)YesYesYesNoAnything you want to find again
A fn-typed fieldYesAs a fieldAs a fieldYesThe document is configuring behaviour

The fourth row is the one the others cannot substitute for. A let and a fn item help you write the data, then vanish. A fn-typed field is data: schema-checked, addressable by path, part of what a host reads back. That is the same distinction Documents, fields and blocks draws between a field and a let, and it holds for functions exactly as it holds for numbers.

A worked example

One file uses every form. Save it as rollout.wcl:

rollout.wclwcl
# rollout.wcl — three services, and the capacity plan derived from them.

@block("service")
type Service {
  @inline(0) name: identifier
  tier:     utf8
  replicas: u32
  cpu:      f64
}

@block("plan")
type Plan {
  @inline(0) name: identifier
  # A function is a value, so a field may hold one.
  headroom: fn(f64) -> f64
  budget:   f64
}

@document
type Cfg {
  @children("service") services: list<Service>
  @child("plan")       plan:     Plan
  requested:   f64
  provisioned: f64
  core:        list<identifier>
  fits:        bool
}

# A `fn` item: named once, called anywhere, invisible to the document.
fn demand(s: Service) -> f64 s.cpu * s.replicas

# A `let` item — the closure below captures it.
let core_tier = "core"

service web { tier = "edge"  replicas = 3u32  cpu = 0.5 }
service api { tier = "core"  replicas = 2u32  cpu = 1.5 }
service db  { tier = "core"  replicas = 1u32  cpu = 4.0 }

plan q3 {
  headroom = fn(cpu: f64) -> f64 cpu * 1.25
  budget   = 12.0
}

requested   = fold(services, 0.0, fn(acc: f64, s: Service) -> f64 acc + demand(s))
provisioned = plan.headroom(requested)
core        = map(filter(services, fn(s: Service) -> bool s.tier == core_tier),
                  fn(s: Service) -> identifier s.name)
fits        = provisioned <= plan.budget
console
$ wcl check rollout.wcl
OK
$ wcl get rollout.wcl requested
8.5
$ wcl get rollout.wcl provisioned
10.625
$ wcl get rollout.wcl core
[api, db]
$ wcl get rollout.wcl fits
true

Four functions, four forms, one file:

Change budget to 10.0 and fits flips to false. The document recomputes because nothing was ever precomputed. How a document evaluates is that story in full.

What a function does not do

The gaps below are the language as it stands, not oversights to be patched around. Each has already appeared above. Here they are in one place.

Where to go next