Bindings and Functions
Philosophy
Keywords
Silt has a small, fixed keyword set. The full keyword list is:
as else fn import let loop match mod
pub return trait type when where
This is a forcing function, not an aesthetic choice. Every time we considered
adding a keyword (if, for, while, mut, async, await,
catch, throw…), we asked: “Can an existing construct handle this?” The
answer was almost always yes.
if/elseis subsumed bymatch.- General-purpose iteration uses
loop, collection traversal uses higher-order functions (list.map,list.filter,list.fold). mutdoes not exist because nothing is mutable.async/awaitdoes not exist because concurrency is CSP-based.try/catchdoes not exist because errors are values.
Concurrency primitives live in modules (channel.new, channel.send,
task.spawn, etc.) rather than as keywords — this keeps the global
namespace clean and avoids the PHP problem of too many bare globals.
The following names are always available without an import: print,
println, panic, Ok, Err, Some, None, plus the primitive type
descriptors Int, Float, String, and Bool (used with
type-directed APIs like json.parse_map). Everything else is reached
through its module, the stdlib’s types and constructors included:
list.Stop(acc), channel.Message(v), time.Monday, http.GET (after
import list, import channel, …), or listed in a selective import
(import channel.{ Message }).
What is not a keyword matters too. true/false are builtin literals.
Ok, Err, Some, None are builtin variant constructors — ordinary
values defined in the prelude. _ is a wildcard pattern token. This keeps
the keyword count honest.
Expression-Based
Every construct in Silt is an expression. A match returns a value. A block
returns its last expression. There are no “statements” — let and when
are statement-level forms inside blocks, but blocks themselves are expressions.
let description = match shape {
Circle(r) -> "circle with radius {r}"
Rect(w, h) -> "rect {w}x{h}"
}
let result = {
let x = compute()
let y = transform(x)
x + y
}
The trade-off: functions that exist only for side effects return () (Unit).
Only the last expression of a block is the block’s value. An expression that
stands before it has no one to give its value to, so it must have none: its
type is (), or it never returns (return, panic). Anything else is an
error, and let _ = ... is how a value is discarded on purpose:
import io
import task
fn main() {
let _ = task.spawn { -> println("in the background") }
match io.write_file("out.txt", "data") {
Ok(_) -> println("saved")
Err(e) -> println("not saved: {e.message()}")
}
}open in playgroundWithout the let _ =, the first line is rejected: this `Handle(())` value is unused; write `let _ = ...` to discard it. A Result left unused is how
an error goes unseen, so its message asks for more: handle it, return it with
?, or discard it with let _ = .... A call whose type nothing else decides
(f() where f is a parameter) has type () when it stands as a statement.
The last expression of a block, and of main, is its value and is not subject
to the rule.
Immutability as Default (and Only Option)
All bindings are immutable. There is no mut, no mutable references, no
assignment to existing bindings. Inside a function body, shadowing is
allowed:
fn main() {
let x = 42
let x = x + 1 -- shadowing, not mutation
println(x)
}open in playgroundWhy no mutation at all? (1) Concurrency safety — immutable values need no locks. (2) Simpler reasoning — values never change after creation.
The trade-off is real: algorithms that naturally use mutation (in-place
sorting, graph traversal with visited sets) require recursion or functional
combinators. Record update syntax (user.{ age: 31 }) is the mitigation —
it looks like mutation but always returns a new value.
Explicit Over Implicit
Silt has no exceptions, no null, no implicit conversions, no implicit error
propagation. 1 + 1.0 is a type error. If a function can fail, its return
type says so. If a value might be absent, its type says so. If control flow
can exit early, the syntax (? or when-else) says so.
One Way to Do Things
match subsumes if. loop subsumes while. String interpolation
"{a}{b}" subsumes concatenation. Module-qualified functions subsume bare
globals. When there is one way, every Silt program reads the same way.
Language Features
Bindings
Every value is bound with let. No var, no mut, no reassignment:
let x = 42
let name = "Robert"
Shadowing creates a new binding with the same name inside a function body:
fn main() {
let x = 1
let x = x + 1 -- x is now 2; the original 1 is untouched
println(x)
}open in playgroundAt the top level a name is bound only once. Two top-level declarations
that bind the same name — two imports of it (import a.{ x } and
import b.{ x }), two import m as n with the same alias, or an import
and a top-level fn, type, let or trait — are an error that names
both sites. Which declaration a top-level name refers to therefore never
depends on the order of the declarations.
Nor does the value of a top-level let. A top-level let is
initialised after every top-level let its value can reach: the ones
it names, and the ones that the functions and methods it mentions read.
Where nothing orders two of them, the one written first runs first.
let total = base() + 1 -- runs second: `base` reads `start`
let start = 10
fn base() -> Int {
start * 2
}
fn main() {
println(total) -- 21
}open in playgroundWhat a let’s value can reach is decided by four rules:
- A function that is named counts as called. So does a function of
another module, and code of another module can call the methods this
module’s impls give that module’s traits (and the builtin traits):
a
letthat mentions another module’s function is initialised after everyletthose methods read. - A method call on a value whose type is a bounded type variable
(
fn f(x: a) where a: Label { x.label() }) reaches every impl of the method in the module, whichever value the call is given. - A
letthat is a plain value runs nothing. A plain value is a literal, a name, a closure, or a list, tuple, record, map, set or variant made of plain values. Such aletneeds only the top-levellets it names outside closures; what the functions it names read does not matter. A table of handlers that read the table is fine:
import list
let routes = [("home", home), ("count", count)]
fn home() -> String {
"home"
}
fn count() -> String {
"{list.length(routes)} routes"
}
fn main() {
println(count()) -- 2 routes
}open in playgroundA closure handed to a function is not a plain value: let f = wrap({ n -> ... f(n - 1) ... }) runs wrap, which may call the closure.
- Showing a value reaches the
Displayimpls of its parts. Interpolation,println,string.fromand.display()call theDisplayimpl written for each type the value is made of: its own, its fields’, its type arguments’. A value of an unknown type (a type variable, an associated type such asSelf::Item) may be of any type: showing it reaches everyDisplayimpl of the module.
A function that shows a value of its own type variable shows what it is
given, so that showing counts where the function is named, at the type
it is named at. label(42) shows an Int, which calls no impl:
import string
type Row {
n: Int,
}
fn label(x: a) -> String where a: Display {
"<{x}>"
}
let width = string.length(label(42)) -- shows an Int: reaches no impl
trait Display for Row {
fn display(self) -> String {
string.pad_left("{self.n}", width, "0")
}
}
fn main() {
println(Row { n: 1 }) -- 0001
}open in playgroundThis goes one step only. A function that hands its own type variable on
(fn wrap(x: a) -> String where a: Display { label(x) }) names label
at a type that is not known there, so wrap(42) reaches every Display
impl of the module, and let width = string.length(wrap(42)) above
would be an error: width -> wrap -> Row.display -> width. The way out
is to call label at the known type (label(42)), or to compute the
value without showing (let width = 4).
A top-level let that can reach itself has no place in the order, and
is an error that names the way round:
let a = f()
fn f() -> Int {
b + 1
}
let b = a + 1
-- error: the top-level `let` 'a' needs its own value to be initialised: a -> f -> b -> a
Destructuring works in let for irrefutable patterns, the ones that
match every value of their type: tuples and records, the constructor of a
single-variant type, an or-pattern whose alternatives cover the type
between them, and any nesting of these:
let (x, y) = (1, "hello")
let User { name, age, .. } = user
List patterns like [a, b, c] are refutable (they fail when the list has a
different length), so let [a, b, c] = xs is rejected. Use match or
when let ... else for those:
match xs {
[a, b, c] -> use(a, b, c)
_ -> handle_other_shape()
}
-- `when let` is a statement that binds in the surrounding scope on success;
-- the `else` branch must diverge (return or panic) so the bindings are sound.
when let [a, b, c] = xs else {
return handle_other_shape()
}
use(a, b, c)
Type annotations are optional (Hindley-Milner infers everything) but useful for documentation:
let x: Int = 42
let transform: Fn(Int) -> Int = { x -> x * 2 }
Functions
Named functions use block bodies. The last expression is the return value:
fn add(a, b) {
a + b
}
fn square(x) {
x * x
}
Parameters of named functions, trait methods and closures take the same
forms: a name or an irrefutable destructuring pattern (a tuple, a record, or
the constructor of a single-variant type), each with an optional type
annotation. A refutable pattern such as Some(x) or [a, b] is rejected;
bind a name and match on it in the body instead.
fn add(x: Int, y: Int) {
x + y
}
fn first((a, b): (Int, String)) {
a
}
fn area(Point { width, height }) {
width * height
}
Closures are values that close over their environment. A closure is
written in braces: its parameters, ->, and its body:
let double = { x -> x * 2 }
fn make_adder(n) {
{ x -> x + n }
}
let answer = { -> 42 } -- no parameters
A closure has no return-type annotation; its type is the type of its body:
let swap = { (a, b): (Int, String) -> (b, a) }
The body is one expression, or statements on lines of their own; the closure
returns the value of the last one. With nothing behind the arrow, { x -> },
it returns ().
let describe = { n ->
let sign = match {
n < 0 -> "negative"
_ -> "not negative"
}
"{n} is {sign}"
}
A body needs no braces of its own, and silt fmt takes them away:
{ x -> { x * 2 } } becomes { x -> x * 2 }. (It leaves them where taking
them away would move a comment, and inside a string it keeps as written.)
No nested named functions. Use let f = { x -> ... } for local helpers.
Named functions are always top-level, keeping scoping rules simple.
Trailing closures: when the last argument is a closure, write it outside the parentheses:
let doubled = [1, 2, 3] |> list.map { x -> x * 2 }
let total = [1, 2, 3] |> list.fold(0) { acc, x -> acc + x }
-- Destructuring in closure parameters
pairs |> list.each { (n, word) -> println("{n} is {word}") }
Return type annotations:
fn add(a: Int, b: Int) -> Int {
a + b
}
Early return with return. Both return and panic() produce the
Never type, which unifies with any other type — so they can appear in any
expression position without causing type errors:
fn get_or_die(opt) {
match opt {
Some(v) -> v
None -> panic("expected a value") -- Never unifies with v's type
}
}