Bindings & functions

Implemented

let, functions, blocks and if/else — the working core of the language today.

Everything in Clay is an expression, and the last expression in a block is its value — an explicit return for early exit is available too and is covered at the end of this page.

let

let binds a name to a value. A top-level let may carry parameter and return type annotations; when present they’re checked against the value, and an annotated and an unannotated definition of the same thing are literally the same object (same hash — see Hashing & storage).

let add = (a: int, b: int): int => a + b

let greet = (name: string): string => {
let greeting = "Hello, "
greeting ++ name
}
A fully-annotated function, and one built from a block with a local let.

Inside a block, a let sees only the bindings written before it — unlike Unison’s own block bindings, which are recursive and see the whole block. Clay’s emitter renames every local binder under the hood to keep that difference from leaking into the hash.

Functions

Functions are arrow expressions: (params) => body. Parameter and return type annotations are optional at the top level — a definition with no annotation at all is inferred (see Types & inference).

Functions as values

A function does not have to be written out. A name can be bound to another function, to a builtin, or to a constructor, and _ in an argument list leaves a hole, giving a function of what is missing (partial application):

let addTo = (a: int, b: int): int => a + b

let inc = addTo(1, _)
let negate = Int.negate
let wrap = Some

let twice = (f, x) => f(f(x))
let three = twice(inc, 1)
Verified through UCM. inc, negate and wrap are stored as function values; wrap has the polymorphic type a -> option<a>.

inc has type int -> int, and callers cannot tell it from a function written with a body. Some is a real constructor term, not a lambda, so wrap hashes the way Unison’s does. A stored value may be polymorphic too (let empty = [] has type list<a>, and each use picks its own element type). Fewer arguments than parameters, with the hole spelled out, is partial application; too many is still a type error.

Delayed computations

thunk(e) delays e until someone calls the result, and force(t) runs it; the type is written thunk<T> and is the same as () => T. It is Unison’s do, and it behaves like it: the parameter it ignores is not forced to be unit, so a thunk can be handed to a function that expects a one-argument function. On its own it still has type () => T.

let apply = (f, x) => f(x)

let delayed = thunk(apply(thunk(7), "ignored"))
let run = (job: thunk<int>): int => force(job)
let answer = run(delayed)
Verified through UCM: answer is 7. The inner thunk is used as a string -> int function.

One Unison detail carries over: a thunk bound with let is generalized there, and its parameter falls back to unit at that point. So let t = thunk(1) is a () => int for good, while apply(thunk(7), "ignored"), where the thunk is an argument, can take anything.

Characters

A char is one Unicode code point, written between single quotes ('a', '\n', '\u{1F600}'). Chars compare and sort by code point, and a string is still a separate type.

let isVowel = (c: char): bool =>
c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'
Verified through UCM.

if / else

if/else is an expression, not a statement, and both branches must produce the same type. else is required (there’s no such thing as a T | unit from an if with no else).

let abs = (n: int): int =>
if n < 0 { 0 - n } else { n }

Early return, and options

An explicit return exits the nearest function early (it also works inside a loop, next to break and continue). It desugars before hashing, so a guard hashes like the equivalent if/else. A function may return an option<T> (Unison’s builtin Optional); the constructors are written Some(x) and None():

let safeDiv = (a: int, b: int): option<int> => {
if b == 0 { return None() }
Some(a / b)
}
A guard with an early return, and an option-typed result.

Take an option apart with switch, or give it a default with ??.

Optional chaining

a?.field reads a field through an option: None stays None, Some(r) becomes Some(r.field). It desugars to a switch before hashing and does not flatten, so pair it with ?? for a default.

type server = {port: int, host: string}
type config = {server: option<server>}

let portOf = (c: config): int => c.server?.port ?? 8080
Verified: a config with no server gives 8080.

The record type on the left must be statically known (an option-typed field of a typed record, or a chain of ?.).