Skip to content

Functional concepts

The functional features are available when a file declares @paradigm functional. The gate covers the five collection builtins, do notation, and the monad keyword. Gating is per file: a file without @paradigm functional cannot call map directly, but it can call a user-defined function that internally uses map. See the paradigm system for the full gating rules and the paradigms guide for a walkthrough.

Five builtins operate on collections. Each takes the collection first and a lambda last.

BuiltinShapeDescription
mapmap(xs, f)a new slice holding f applied to each element
filterfilter(xs, pred)a new slice of the elements where pred is true
reducereduce(xs, f) -> (T, error)folds with the first element as the seed, over the rest
foldfold(xs, init, f)threads an accumulator left to right through f(acc, x)
foreachforeach(xs, f) -> voidapplies f to each element, for its side effects

The collection argument is a slice-typed value or an array, viewed as a slice. The function argument is a lambda, and lambdas capture outer variables by immutable copy, taken when the lambda is created; see functions and lambdas.

map and filter build their results in fresh heap-backed slices, so a result may be returned from the function that produced it, unlike a slice viewing a frame-local array, which is rejected as an escape.

reduce has no separate seed, so an empty collection has nothing to reduce. It therefore returns (T, error), and the error fires on an empty input with the message reduce on empty slice. The must-handle rule applies; see error handling. fold takes an explicit initial accumulator and has no error case.

Each builtin’s argument count is checked. fold takes exactly three arguments, and map, filter, reduce, and foreach take two, so a stray extra argument is rejected with fold takes 3 argument(s), not silently ignored.

builtins.dusk
@paradigm functional
func main() -> int32 {
nums: int64[] = [1, 2, 3, 4, 5]
doubled := map(nums, lambda (n: int64) -> int64 { return n * 2 })
evens := filter(nums, lambda (n: int64) -> bool { return n % 2 == 0 })
sum := fold(nums, 0, lambda (acc: int64, n: int64) -> int64 { return acc + n })
prod, e := reduce(nums, lambda (a: int64, b: int64) -> int64 { return a * b })
e.ignore()
foreach(doubled, lambda (n: int64) -> void { println(n) })
foreach(evens, lambda (n: int64) -> void { println(n) })
println(sum)
println(prod)
return 0
}

The monad keyword declares a named group of monadic operations. A monad provides a unit operation that wraps a value and a bind operation that chains computations. A block that defines only one of the pair is rejected at parse with a monad block must define both 'bind' and 'unit'. The block belongs to the functional paradigm, so declaring one in a file without @paradigm functional is rejected with monad block requires the functional paradigm.

monad Identity {
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x)
}
func unit(x: int64) -> int64 {
return x
}
}

The block’s methods are namespaced under the monad’s name, as Identity.bind and Identity.unit. The namespace is what lets several monads coexist in one module, each with its own bind and unit; a do Name { ... } block selects which pair to use.

Do notation sequences monadic computations. A do block contains any number of x <- e binds and bare expressions, and must end in an expression, which is the block’s result. A bare expression is an anonymous sequencing step. Other statement forms are not allowed inside a do block.

r := do {
a <- 7
b <- 3
a * b
}

The block desugars, before name resolution and type checking, into nested calls:

do { x <- m; y <- n; x + y }

becomes

bind(m, lambda (x) { return bind(n, lambda (y) { return unit(x + y) }) })

A bare do { ... } desugars against the top-level bind and unit in scope. A named do Name { ... } desugars against Name.bind and Name.unit from the matching monad block. The continuation lambda’s parameter and return types are read from the chosen bind’s second parameter, a function type (A) -> B.

baredo.dusk
@paradigm functional
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x)
}
func unit(x: int64) -> int64 {
return x
}
func main() -> int32 {
r := do {
a <- 7
b <- 3
a * b
}
println(r)
return 0
}

Two monads in one program, each selected by name:

monads.dusk
@paradigm functional
monad Identity {
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x)
}
func unit(x: int64) -> int64 {
return x
}
}
monad Doubler {
func bind(x: int64, f: (int64) -> int64) -> int64 {
return f(x) * 2
}
func unit(x: int64) -> int64 {
return x
}
}
func main() -> int32 {
a := do Identity {
x <- 10
y <- 20
x + y
}
println(a)
b := do Doubler {
x <- 3
y <- 4
x + y
}
println(b)
return 0
}

Do notation composes over any generic monad, not only a bind and unit already ground to concrete types. This landed in 0.4.3. The desugar emits its continuation chain over an open type hole, and monomorphization resolves and instantiates the bind and unit pair fresh at each do site rather than once for the whole program. It reads the types from the site, an argument pass, an expected type or annotation pass, and a continuation body pass, with the first pass to pin a concrete type winning. A ground monad like the Identity above and a fully generic one like a user Box<T> thread through do the same way.

@paradigm functional
struct Box<T> {
v: T,
}
monad Box {
func bind<A, B>(m: Box<A>, f: (A) -> Box<B>) -> Box<B> {
return f(m.v)
}
func unit<A>(x: A) -> Box<A> {
return Box { v: x }
}
}
func main() -> int32 {
r := do Box {
a <- Box { v: 3 }
b <- Box { v: 4 }
c <- Box { v: 5 }
a * b + c
}
println(r.v) // 17
return 0
}

A do over a type that has no matching monad block is rejected at the names its desugar calls, undefined name '<Name>.bind' and undefined name '<Name>.unit'.

Because the continuation carries that open type hole until monomorphization closes it, a second, types-only pass re-runs the type checker over the fully concrete program, recovering the width and type checks the open hole would otherwise let the continuation body skip. A width mix that used to truncate silently inside a generic do is now caught exactly as it is in ordinary code, arithmetic mixes int32 and int64; match the widths. The recheck is general, not special-cased for do: it also catches a width mismatch hiding inside any ordinary generic function body.

The spec lists five monads to ship through import: Maybe<T>, Either<L, R>, Result<T, E>, IO<T>, and the list monad. Four of them ship today: std.functional.maybe, std.functional.either, std.functional.result, and std.functional.io. Only the list monad is still to come. Maybe, Result, and IO each carry a monad block, so they compose through do. Either ships plain helpers with no monad block, because a unit for it would have to pick a free Left and there is no canonical one, so do Either { ... } stays unsupported by design.

Importing a module is separate from declaring a paradigm, so a file can use Maybe and its helpers without @paradigm functional:

maybe.dusk
@import std.functional.maybe
func main() -> int32 {
m: Maybe<int64> = Maybe.Some(42)
println(unwrap_or(m, 0))
none: Maybe<int64> = Maybe.None
println(unwrap_or(none, 99))
return 0
}

A monad’s bind and unit are plain functions the do desugar calls, not methods on the value. A method call on an enum value, m.unwrap(), is rejected, 'unwrap' is not defined; methods on the enum 'Maybe' are not supported, match on it instead, since only struct receivers dispatch a method. Read a Maybe back with match or a helper like unwrap_or.

std.functional.io ships IO<T> as a monad IO { ... } block over struct IO<T> { run: collector<() -> T> }. As of 0.5.3 it is a true lazy monad: bind and unit never run anything, they build a new collected thunk that captures the source and the continuation, so a whole do IO { ... } chain is a suspended computation sitting on the collected heap the moment it is built. run(io: IO<A>) -> A is the one effect boundary; it forces the thunk on the calling thread and returns the value the chain produces. Because the thunk lives on the collected heap, an IO chain inherits collector confinement: it cannot cross a spawn or submit capture, a channel, or an interface box. The shipped helpers yield IO<bool> rather than IO<void>, since void carries no value for bind to thread through a chain.

Building or running an IO chain touches neither the event loop nor the thread pool. Before 0.5.3 the eager IO minted a future and offloaded its value onto the pool, so a program had to bring the loop and the pool up with loop_init and pool_start before the first run. That ceremony is gone: run forces its thunk directly, and a program that kept the loop and pool around an IO chain for no other reason can drop it.

std.functional.result ships Result<T, E> as enum Result<T, E> { Ok(v: T), Err(e: E) }, with a monad Result { ... } block fixed to E = string, the common case, since a generic E cannot flow through do inference. do Result { ... } threads Ok values and short circuits on the first Err. A caller needing a different error type uses the plain constructors and helpers instead of do Result { ... }.

See std.functional for the full API of all four modules, and enums for the sum types that back them.