Values & variables
The vocabulary every Hale program is built from. A variable is introduced with let:
The vocabulary every Hale program is built from.
A variable is introduced with let:
let greeting = "hello";let count = 3;let ratio = 0.5;let ready = true;Hale infers the type from the value. You can write it explicitly when you want to be sure, or when there’s no value to infer from:
let count: Int = 3;Immutable by default
Section titled “Immutable by default”A plain let binding can’t be reassigned. To make a variable
you can change, add mut:
let total = 0;total = total + 1; // ERROR: total is immutable
let mut total = 0;total = total + 1; // fineImmutable-by-default is a per-binding property, not a property
of the type. There’s no separate “constant” concept for locals —
let is the constant, let mut is the variable. (Top-level
program constants use const NAME: T = ...; and are written
SCREAMING_SNAKE_CASE.)
const is top-level only: it goes beside your locus and
fn declarations, not inside a locus body. A top-level const
is already in scope inside every locus of the program, so that
is where a shared constant belongs; if each instance should
carry its own value, that’s a params field with a default
(params { limit: Int = 7; }, read as self.limit). A const
written inside a locus body is an error at the const keyword,
saying both.
A let is visible from its statement to the end of its block. A
second let x later in the same block rebinds the name to the new
value from there on; a let x inside a nested block shadows an
outer x — a parameter’s or an outer let’s — there and there only,
and the outer binding is what the rest of the function sees. The
slot a let binds lives until the function exits (that is what a
loop’s reclamation is about), but the name is the block’s.
A name that isn’t declared is an error
Section titled “A name that isn’t declared is an error”Reading a name nothing declares fails hale check, at the name:
main.hl:3:18: type error: unknown identifier `totl`: no binding,param, const or declaration with that name is in scope — did youmean `total`?hale check app.hl checks that file the way hale build app.hl
builds it: the file and what it imports, nothing else. So if your
program is several files in one directory and main.hl reads a
const that limits.hl declares, check the directory — hale check . — not the one file; checked alone, the file is refused exactly as
its build would be, and the check adds a note saying the other files
beside it were not loaded.
A name a block bound and released (if take { let x = 1; } return x;) is the block’s, so reading it after the block is refused too,
and the message says the block has ended.
A type name that isn’t declared is an error too
Section titled “A type name that isn’t declared is an error too”The same rule holds where a type is written. The one that catches
everybody is capitalization — Hale’s primitives are Int, Float,
String, not int, float, string:
main.hl:1:16: type error: unknown type `int`: no type, enum, locus,interface or alias with that name is declared — did you mean `Int`?fn helper() -> int { ^^^It is reported wherever the name is written: a parameter, a return
type, a struct field, a params field, a let ascription. Same
whole-program rule as above, and the same permissive reading for one
file of a multi-file seed — a sibling may declare the type.
A name with a :: in it (lib::Thing, std::text::Sink) is not
held to this: it names something in another seed, which the checker
can only see once the whole seed is in front of it.
A field declared twice is an error
Section titled “A field declared twice is an error”A type names each field once, and an enum names each variant once.
Declaring a a second time in the same type is an error at the
second declaration, which names the first: field ais already declared in typeT``, with the first declaration’s line beside it.
The same holds for a locus’s or a perspective’s params, a
contract’s members and an interface’s methods — a name is one
slot.
Some ordinary words are reserved
Section titled “Some ordinary words are reserved”Hale’s keywords are ordinary English: epoch, where, rich,
tier, capacity, release, restart, run, drain, on,
of, publish. They are good words, which is exactly why they
are easy to reach for as a name — and they are not available as
one:
params { epoch: Int = 0; } // nolet where = 3; // nofn restart() { } // noThe compiler says so at the word, once, naming what you were declaring:
work.hl:8:9: parse error: `epoch` is a reserved word and cannotname a params field; rename it (Hale has no escaped-identifierform — spec/tokens.md lists every reserved word) epoch: Int = 0; ^^^^^There is no way to escape or quote a reserved word into a name.
Rename it — epoch_id, where_clause, restart_now — and
nothing else about the program changes. Every use of the same
word further down is part of that one mistake, so you get one
error, not one per line, and the rest of the file still parses
normally.
The full list lives in spec/tokens.md. A word being a keyword
somewhere does not always make it unavailable everywhere: a few
are recognized only in position (mode, pool, with, seed
are free as names), and a few of the hard ones are deliberately
admitted as field names, so type Cmd { run: Int; tier: Int; }
is fine even though let run = 1; is not.
The primitive types
Section titled “The primitive types”These are the scalar types built into the language:
| Type | What it holds | Literal examples |
|---|---|---|
Int |
64-bit signed integer | 0, 42, 1_000_000, 0xFF, 0b1010 |
Float |
64-bit IEEE float | 3.14, 1.0e-3, 2.5 |
Bool |
true / false | true, false |
String |
UTF-8 text | "hello", "line\n" |
Decimal |
exact fixed-point number | 1.50d, 0.00d |
Duration |
a span of time | 100ms, 5s, 90min |
Time |
a wall-clock instant | `2026-05-08T12:00:00Z` |
Bytes |
a binary blob | b"\x00\x01\xff" |
Decimal, Duration, and Time are first-class — not strings
you parse, not integers you remember the units of. They get
their own chapter (Math, money & time) because
they’re a real ergonomic upgrade over what most languages give
you.
Underscores in number literals are just for readability
(1_000_000). Integers default to Int, decimals-with-a-point
default to Float; the d suffix makes a Decimal.
Strings
Section titled “Strings”Double-quoted, with the usual escapes (\n, \t, \", \\,
\xNN). One extra form — the f-string:
let name = "world";let hi = f"hello {name}"; // f-string interpolationAn f-string evaluates the expressions inside {...} and renders
them into the text. Use {{ and }} for literal braces. A plain
"..." string is not interpolated — "x={x}" prints the
braces — and the compiler warns if you write that by accident.
f-strings also take a format spec (f"{n:>8}", f"{r:.2}") and
render whole structs. Both are in
Strings & text.
Printing values
Section titled “Printing values”println and print take any number of arguments and
concatenate them. to_string turns a value into text when you
need it as a String:
fn main() { let n = 41; println("n + 1 = ", n + 1); // n + 1 = 42 let s = to_string(n + 1); // "42" println(s);}println, print, to_string, and len are builtins —
called as plain functions, not methods. You write len(s), not
s.len(). (Methods with . come later, on loci and your own
types.) If you reach for the spelling another language uses —
s.len(), std::str::len(s), std::math::abs(x),
std::io::println(x) — the checker names the builtin for you
rather than guessing at a near-miss stdlib name.
Printing isn’t limited to single values: a struct, tuple or array
of printable things prints as a whole, recursively — which is
what you want when you’re mid-debug and don’t yet know which
field is wrong. Two things deliberately don’t print: a locus
and Bytes. See
Strings & text.
Next: Math, money & time.