Functions

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.

§ 1A 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 from chapter 1, doing 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)
text
$ 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.

§ 2fn items

Writing fn before a name, at file or block scope, binds 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. Chapter 1 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.

§ 2.1let 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 is scaffolding that helps you write the data — 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)
text
$ 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.

§ 2.2What 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.

Being indexed has a second, sharper effect: a duplicate name is caught. Two fn items sharing a name are a parse error. Two let bindings sharing a name are not, and the first one silently wins.

text
$ 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))
text
$ 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 may be written 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.

§ 2.3Where a fn item may sit

Anywhere a let item may sit: the top level of a file, or inside a block. Inside a block it is scoped to 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)
}
text
$ 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 the body is written, 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)
text
$ 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: 256 nested calls, after which evaluation stops with call depth limit exceeded (max 256).

§ 3Function 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<…>, 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)
text
$ 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:

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

  × field 'apply' declared as fn(i64) -> i64 but value is i64

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, is going to call it. Do not declare one on data you expect to round-trip through JSON.

§ 4Higher-order functions

A function that takes or returns another function is called higher-order. WCL needs no machinery 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)
text
$ 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)
text
$ 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)).

§ 4.1Returning a function

The return type of a literal may itself be a function type, which is how you curry:

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)
text
$ 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 — a closure, which is the next section.

§ 5Closures and capture

A function literal captures the local bindings in scope where the literal is written. It captures them by value, at the moment the literal is evaluated:

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) }
text
$ wcl get capture.wcl a
1

Names at document scope are not captured, and do not need to be. A document is immutable once it is open. A let item, a field or a block therefore resolves correctly 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:

Close over what you use

A free name is one the function neither captured nor took as a parameter. The call site resolves it, not the place you wrote the function. A function whose body names ghost picks up whatever ghost the caller holds in a local binding. At the next call site, which has none, it fails. Nothing warns you. Write functions that use their parameters, their captures and document-scope names; treat anything else as a bug you have not hit yet.

§ 6No defaults and no named arguments

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

There are no optional parameters. ? marks an optional field in a type declaration; in a parameter list it does not parse:

text
$ wcl check optional.wcl
wcl::parse

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

There are no default arguments:

text
$ 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 ::

text
$ 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. What the language checks of that call is exactly one thing — the count:

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

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

It does not check argument types. Pass a utf8 where the signature says i64 and the call evaluates cleanly, with the wrong value flowing on. Nor does it check the return type: fn f(x: i64) -> utf8 x + 1i64 returns the number 2, and the mismatch surfaces only later, when a field declared utf8 receives it. A signature is a contract you are keeping, not one the evaluator enforces for you.

§ 6.1What this costs, and why it is worth naming

Arity is the one positional mistake WCL catches, and 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. ? cannot appear in a parameter list, so every wdoc function annotates its optionals as though they were required, and lets the none flow through. Second, ela and eli take the same number of arguments, so arity cannot separate them. Call one where you meant the other and it silently drops the id, or the attrs. Pick by what you are passing. Documents, pages and sites documents the family; Writing your own blocks is where you will be calling it.

§ 7Four ways to write a function down

The forms above are not interchangeable, and this is the comparison. All four hold the same kind of value. 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 are scaffolding: they 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 chapter 1 draws between a field and a let, and it holds for functions exactly as it holds for numbers.

§ 8A worked example

One file, using 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
text
$ 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.

§ 9What 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; this is the list in one place.

§ 10Where to go next