Declarations
A declaration introduces a symbol into the current scope. Epsil has two declaration keywords:
letdeclares a mutable symbol.constdeclares an immutable symbol.
let x = 5
const c = 6.28
Reach for const when the name stands for something fixed — a physical
constant, a conversion factor, a lookup table — so that an accidental write is
reported. Use let for anything that varies: accumulators, loop state,
values you refine as you go.
A type annotation also implies a declaration, even without a keyword:
x: real = 5
is a declaration of x with type real, exactly as if it had been written
let x: real = 5. The keyword is only mandatory for an untyped
declaration — that's what distinguishes a declaration from a plain
reassignment (see below).
Destructuring declarations
A let or const may bind the components of a tuple in one statement:
divmod(a, b) = (floor(a / b), a % b)
let (q, r) = divmod(17, 5)
(q, r)
// ➔ (3, 2)
The pattern is a parenthesized list of at least two elements, each a bare
symbol, a _ (which skips that position), or a nested tuple pattern:
let ((a, b), _, c) = ((1, 2), 99, 5)
a + b + c
// ➔ 8
The pattern is irrefutable in form — no literals, pins, or guards (use
match for conditional destructuring). The value is
evaluated once; it must be a tuple of the same shape, otherwise the
declaration yields an incompatible-type error value and binds nothing.
With const, every bound name is a constant. An initializer is required, and
a type annotation is not accepted on a pattern. Duplicate names anywhere in
one pattern are a diagnostic.
Destructuring assignment
The same pattern may appear on the left of an assignment, to write bindings that already exist instead of declaring new ones:
let a = 1
let b = 2
(a, b) := (b, a)
(a, b)
// ➔ (2, 1)
The right side is evaluated once, in full, before any target is written,
so a swap means what it reads — (a, b) := (b, a) exchanges the two values
rather than assigning b to both. The same holds for a rotation
((a, b, c) := (c, a, b)) and for the pair-carrying loop step that is the
usual reason to want this:
let a = 0
let b = 1
for k in 1..10 {
(a, b) := (b, a + b)
}
a
// ➔ 55
The pattern grammar is exactly the one above — at least two elements, each a
bare symbol, a _ skipping that position, or a nested tuple pattern — and a
shape mismatch is the same incompatible-type error value, which writes
nothing: the whole pattern is matched before any target is written, so a
mismatch nested under a position that would have bound leaves that one alone
too.
The differences from a destructuring let are the ones assignment always has:
the targets keep their identity and their declared type (a value that does not
fit a target's type is an error value), and assigning to a const fails.
Those two failures are found only by attempting the write, so unlike a shape
mismatch they are not atomic — targets earlier in the pattern have already
been written and stay written.
The assignment operator must be spelled :=. A statement-leading (a, b) = …
is a comparison, not an assignment — a parenthesized left side is not a
binding target, so the bare = reads as Equal. Because that is almost
always a typo for the destructuring assignment, it is
diagnosed.
Declaring a type
A third declaration keyword, type, introduces a type name rather than a
symbol — and, with it, a constructor of the same name:
type point = tuple<x: number, y: number>
type alias pair = tuple<number, number>
let p = point(1, 2)
let a: pair = (1, 2)
type declares a new, distinct type; type alias declares another name for
an existing one, and takes a type-parameter clause if it needs one
(type alias Pair<T> = tuple<T, T>). Unlike let and const, type is not
a reserved word — only these statement shapes claim it. See
Declaring a type for the whole story.
Function-type annotations bind their parameter names
A parameter name binds wherever it appears. When a declaration's annotation is a function type written out at the declaration site with named parameters, those names become the parameters of the declared function — the initializer is its body:
const f : (x: number) -> number = x^2 + 2x + 1
f(3)
// ➔ 16
This is the same function as = (x) => x^2 + 2x + 1, and the same as the
definition form f(x: number) -> number = x^2 + 2x + 1. The initializer may
instead be an explicit lambda; the annotation's names must then agree with the
lambda's (a disagreement is a diagnostic, with a fixit) — or leave the
annotation's parameters unnamed, and let the lambda name them:
const g : (number) -> number = (x) => x + 1
So a name appears in one place (or in both, agreeing) — never with two
meanings. These declared names are also what callers use to pass
named arguments — f(x: 3) — so
renaming a parameter is a visible change to the function's interface. When the annotation is named, the initializer is read as a pointwise
body; when it is unnamed, the initializer must be a function value, as in
const h : (number) -> number = g.
The names bind only where they are written: an annotation through a
type alias never binds (its names are documentation), a zero-parameter
signature has nothing to bind (const t : () -> number = makeCounter() keeps
meaning what it says), and for a curried signature only the outermost
arrow binds — const add : (x: number) -> (y: number) -> number = (y) => x + y
binds x around an explicit inner lambda. Generic (a where clause), effectful,
optional/variadic, and partially named signatures do not bind either; give
those an explicit lambda.
Reassignment vs. declaration
A bare x = 5 — no let/const keyword, no type annotation — is not
declaration syntax: it is an assignment:
x = 5
Assigning to a name that was never declared does establish it, but let is
the explicit and idiomatic way to introduce a mutable binding.
Reassigning a symbol that was declared const produces an
error value, not a parse error or a
thrown exception:
const c = 1
c = 2
c = 2 still parses as a perfectly ordinary assignment; the failure happens at
evaluation time, and its result is an error value.
A declaration with no initializer declares the name without giving it a value:
let x: real
let y
Without an annotation, the type is inferred from the initializer — let x = 5
declares x as an integer.
Constness is a property of the binding, not of the type: const says that
this name will not be written again, and says nothing about the value it
holds — there is no such thing as a constant type. See
Declarations for the underlying
representation.
Scoping
Declarations live in the current scope. A program (a notebook cell or a
chain of cells sharing one engine scope) declares at the top level; a block
introduced by if/else/while/for, or a function body, pushes its own
lexical scope, so a let/const inside a block does not leak into the
enclosing scope.
A name is declared once per scope. A let/const of a name that the same
scope already declares — an earlier let/const of the same block or
program, a parameter of the function whose body this is, or the index of the
loop whose body this is — is the variable-redeclaration error, reported
before the program runs:
function f(x) {
let x = x + 1 // error: x is a parameter of f
x
}
To update a binding, assign to it (x = x + 1); to hold a second value,
choose another name. A let in a nested block is not a re-declaration:
it shadows the outer name for that block, and its initializer reads the outer
value.
let t = 1
for k in 1..3 {
if k > 1 {
let t = t * 2 // shadows the outer t; reads 1, so t is 2 here
t
}
}
Across programs — a re-run notebook cell, a later REPL line — a top-level
let re-declares legally; only a repeat within one program is reported.
Type declarations are the exception: types (and their
constructors) are global — a type statement is only allowed at the top
level of a program, and the declared name means the same thing everywhere on
the engine.
let and const are the binding keywords. There is currently no compound
assignment (+=); destructuring declarations (let (x, y) = t) and
destructuring assignments ((x, y) := t) are described above.