Math, money & time
Arithmetic, and three types that save you from classic bugs. The operators are what you'd expect:
Arithmetic, and three types that save you from classic bugs.
Arithmetic
Section titled “Arithmetic”The operators are what you’d expect:
let a = 7 + 3; // 10let b = 7 - 3; // 4let c = 7 * 3; // 21let d = 7 / 3; // 2 — integer divisionlet e = 7 % 3; // 1 — remainderComparison and logic:
let bigger = a > b; // Boollet between = a > 0 && a < 100;let either = ready || forced;let negated = !ready;Bitwise operators (& | ^ << >> ~) are available on Int.
Comparisons don’t chain: write a < b && b < c. On numbers,
a < b < c is refused — it would compare the Bool from a < b
with an Int — so the maths-notation habit can’t slip through
silently.
&& and || short-circuit: the right side is evaluated only when
the left side doesn’t already decide the answer. So a guard protects
what follows it:
let safe = d != 0 && total / d > 10; // never divides by zerolet found = cached || lookup(key); // lookup runs only on a missInt and Float
Section titled “Int and Float”Int is 64-bit signed; Float is a 64-bit IEEE double. Hale
widens Int to Float automatically where it’s unambiguous —
at a let with a Float annotation, when passing an Int to a
Float parameter, when one side of an arithmetic or comparison
operator is a Float, when a Float field of a type literal
is given an Int, and when a fn declared -> Float returns an
Int (fn half(n: Int) -> Float { return n; }; a fallible fn’s
success return does not widen):
let n = 3;let x: Float = n; // 3.0 — widened at the bindinglet r = std::math::sqrt(16); // 4.0 — the Int argument widenslet y = 2.0 * 3; // 6.0 — Int 3 promoted to FloatOnly an Int widens: a quantity such as 5ms is not one, so
let t: Float = 5ms; is refused, and a locus’s Float param
takes a Float (Worker { rate: 3.0 }).
Going the other way loses information, so it’s explicit:
let n = Int(3.9); // 3 — truncates toward zeroFloat(x) is the same cast in the widening direction, for the
places the implicit rule doesn’t reach — mid-expression, most
often:
let hits = 3;let total = 8;let rate = Float(hits) / Float(total); // 0.375, not 0Those two are the only casts. String, Bool, Bytes and the
rest are type names, not conversions: String(x) calls nothing
and is refused.
When you’d rather name the conversion — or need it mid-expression
where the implicit widening doesn’t reach — std::math has both
directions as functions:
let f = std::math::int_to_float(42); // 42.0let m = std::math::float_to_int(3.99); // 3 — round toward zeroThey’re the same sitofp / fptosi conversions as the casts,
just callable anywhere — so numeric code never has to launder a
value through to_string + parse_float to change its type.
When you want a Float rounded to an Int rather than
truncated — building an integer field out of a Float quantity,
say — reach for round; trunc is the toward-zero sibling:
let a = std::math::round(3.7); // 4 (Int)let b = std::math::round(2.5); // 3 — half away from zerolet c = std::math::round(0.0 - 2.5); // -3let d = std::math::trunc(3.7); // 3 — toward zero, like float_to_intBoth return an Int directly. (floor / ceil below return a
Float; wrap them in float_to_int if you need an Int.)
The standard library covers the rest: std::math::sqrt,
exp, log, pow, floor, ceil, the trig functions, and
so on.
Decimal — exact numbers
Section titled “Decimal — exact numbers”Float is wrong for money. 0.1 + 0.2 is not 0.3 in any
IEEE-float language, and rounding error compounds. Hale gives
you Decimal: a fixed-point type with exact arithmetic. Write
the literal with a d suffix.
let price = 19.99d;let qty = 3.0d;let total = price * qty; // 59.97 — exact, no driftPrinting trims trailing zeros (12.50d prints as 12.5 — the
value is the same number); when a display needs fixed places,
std::decimal::format(price, 2) renders exactly two fraction
digits with half-up rounding.
Use Decimal for prices, balances, quantities, anything where a
penny of rounding error is a bug. Use Float for measurements,
ratios, and math where approximation is fine. The two never mix
implicitly — there is no silent Decimal/Float conversion, and
not even Decimal * Int (the quantity above is 3.0d, not 3), so
you can’t accidentally launder exactness away.
Duration — time spans with units
Section titled “Duration — time spans with units”A duration is a length of time, written with a unit: ns, us,
ms, s, min, h or day.
let timeout = 5s;let frame = 16ms;let shift = 8h;let week = 7day;let compound = 1h + 30min; // durations add upA literal has one unit, so a mixed span is a sum: 1h30m is refused,
the message saying to write 1h + 30min. Minutes are min (5m is
refused and says so), and a day is day: 3d is the Decimal 3
(above), never three days.
No more “is this milliseconds or seconds?” — the unit is part of
the literal. Those units are not built into the compiler: the standard
library declares them, the way a program declares its own units
(unit tick = 10 ms; joins them), and Duration is the quantity they
count, in nanoseconds:
unit ns;unit us = 1_000 ns;unit ms = 1_000 us;unit s = 1_000 ms;unit min = 60 s;unit h = 60 min;unit day = 24 h;type Duration = quantity Int in ns;type Time = point Duration;So every duration literal is a count of nanoseconds (16ms is
16,000,000), and a duration prints as one: println(frame) shows
16000000ns. Durations do arithmetic and comparison:
let total = timeout + frame;if elapsed > timeout { /* ... */ }They also scale by plain integers — the shape you need when the number of units arrives at runtime (a computed retry count, a millisecond value from an FFI boundary):
let backoff = tries * 100ms; // Int * Duration → Durationlet slice = timeout / workers; // Duration / Int → Durationlet half = timeout / 2 or floor; // a literal divisor: say how to roundlet frames = timeout / frame; // Duration / Duration → Intfn sleep_ms(ms: Int) { std::time::sleep(ms * 1ms); }Dividing by a number you wrote down is a question with a remainder
(5s / 3 is not a whole number of nanoseconds), so the program says
what happens to it: or floor, or ceil, or trunc, or half_up,
or half_even, or or <value> for a duration to use when it does
not divide evenly. A divisor known only at run time divides as
integers do. Two durations divide to a plain count. (Duration * Duration is rejected — ns² isn’t a thing.)
This is also what the runtime’s sleep takes:
std::time::sleep(100ms);Time — wall-clock instants
Section titled “Time — wall-clock instants”A Time is a specific instant — nanoseconds since the Unix epoch,
UTC — written as an ISO-8601 literal in backticks:
let launch = `2026-05-08T12:00:00Z`;let precise = `2026-09-14T08:30:15.25Z`; // a fraction, up to nanosecondsThe literal is checked when the program is: `2026-05-08T12:00:00+01:00`
is a compile error, because a local time read as UTC in silence is the
bug this type exists to prevent. An instant shifts by a Duration and
two instants differ by one — those are the only arithmetic it admits —
and it orders:
let deadline = launch + 30min;let slack = deadline - std::time::current(); // a Durationif std::time::current() > deadline { escalate(); }println(deadline); // 2026-05-08T12:30:00Zstd::time::current() is the wall clock as a Time; iso8601(t) and
parse_time(s) go to and from text (parse_time is fallible, so a
malformed string is an or branch, not a sentinel); unix(t),
nanos(t), from_nanos(n) and time_from_unix(secs) are the integer
views. now() keeps returning epoch seconds as an Int for code that
wants a number.
For measuring elapsed time, reach for the monotonic clock — it never jumps backward when the wall clock is adjusted:
let start = std::time::monotonic(); // a Duration since bootdo_work();let took = std::time::monotonic() - start;println("took ", took);std::time::now() gives wall-clock seconds since the Unix
epoch when you genuinely need calendar time; monotonic() is
the basis for anything timing-related.
Identities and ranges
Section titled “Identities and ranges”Some integers must not mix. An order id is not a sequence number, and
neither is a count you can add to. Some integers have a range: a byte
is 0..256, a session slot 0..64. Say so in the type:
type OrderId = distinct Int;type Byte = Int { range: 0..256; }type Session = distinct Int { range: 0..64; }
fn main() { let id: OrderId = 4; // a literal is checked against the type let b: Byte = 200; let total: Int = b + 55; // a byte is an Int, for free let slot = Session(70) or 0; // 70 is outside 0..64: the substitute let top = Byte(total + 10) or clamp; let raw: Int = Int(id); println(id, " ", total, " ", slot, " ", top, " ", raw);}A distinct Int is an identity: it compares with itself and with
nothing else, it has no arithmetic (id + 1 is an error), and it
never passes for an Int. You cross explicitly, OrderId(n) in and
Int(id) out. A type with a range: is a range: it is an Int
wherever an Int is expected, and its arithmetic is an Int’s.
Going into a range can fail, so a narrowing such as Session(n)
says what becomes of a value outside it, with or:
or 0, any value of the type, held to its range;or clamp, the nearest bound;or wrap, around the range (-1becomes the top);or handler(err), given aRangeErrorwith the value and the bounds;or raise, to hand theRangeErrorto the caller.
A literal outside the range is an error where you wrote it (let b: Byte = 300;), and a narrowing with no or is too. spec/units.md § Conversions has every rule.
Your own units
Section titled “Your own units”Duration is not the only number with a unit, and its units are
declared the way a program declares its own (above). Money is counted
in cents, sizes in bytes, rates in basis points, and a program
declares those units and the integer types counted in them:
unit cent;unit USD = 100 cent;unit pct = 1/100;unit bp = 1/100 pct;
type Money = quantity Int in cent;type Ledger = Money { round: half_even; }type Rate = quantity Int in bp;
fn main() { let fee = 1_250_000USD * 3bp; // exactly 375 USD let odd = 1_234_567USD * 3bp; // 370.3701 USD: still exact here let paid: Ledger = odd; // rounded where the policy is let cents = odd.in(cent) or ceil; // or name the rounding yourself println(fee, " ", odd, " ", paid, " ", cents);}A unit is a name and, optionally, what it equals: one USD is
100 cent, and bp is a hundredth of a percent, itself a hundredth
of the number one. A quantity counts an Int in one of them. A
literal names its unit with no space, 3bp, 1_250_000USD, and
counts in its quantity’s unit: 1_250_000USD is 125,000,000 cents.
Arithmetic keeps every value exact. fee is not rounded to
cents: multiplying money by a rate gives money counted in the product
of the two units, here a ten-thousandth of a cent, and it prints that
way (375000000 Money in 1/10000 cent). Rounding happens only where
you say so, and you have to say so wherever a value goes somewhere
coarser:
- a type with
{ round: … }(hereLedger,half_even) rounds what is stored into it, with no more ceremony; .in(cent) or floor(orceil,trunc,half_up,half_even) rounds at the site;.in(cent) or 0keeps the value only when it is a whole number of cents.
let bad: Money = odd; is an error: Money has no policy, and the
message says the division by 10,000 has a remainder to decide about.
Time works the same way, because Duration and Time are these
declarations too (the standard library’s, above): a quantity counted in
nanoseconds and a point over it.
type Bucket = Duration in 100ms { round: floor; }
fn main() { let d = 3s + 500ms; // 3500000000ns: a Duration counts nanoseconds let whole = d.in(s) or floor; // 3s: the policy says what becomes of the rest let (secs, rest) = d.split(s); // 3 and 500000000ns, nothing lost let n: Int = d / 1ms; // 3500: two durations divide to a count let b: Bucket = d; // 35 buckets of 100ms let start = Time(1_000s); let later = start + d; // a point moves by a quantity let took = later - start; // and two points differ by one println(d, " ", whole, " ", secs, " ", rest, " ", n, " ", b, " ", took);}A point (Time) is a position, a quantity (Duration) a
distance: two points subtract to a quantity, a point plus a quantity
is a point, and two points do not add. A point may declare where its
zero is (type Celsius = point TempDelta { origin: 273_150 mK; }),
and Kelvin(c) converts across the two zeros.
An array literal may mix units of one quantity. Each literal counts in
its quantity’s unit, so let a: [Money; 2] = [3cent, 2USD]; holds 3
and 200 cents and [1s, 500ms] is two Durations. A value at another
unit is converted from its own: with no type to fit, [whole, 500ms]
is counted in the finer unit, as a sum is: both in nanoseconds.
Mixing kinds is an error at the operator, naming both declarations:
5ms + 4KiB adds time to bytes, bid + ask adds two prices,
d > 0 compares a span with a bare number (write d > 0ms). An
Int becomes a quantity by a unit (n * 1cent), and a quantity’s
count is a quotient (q / 1cent). The standard library declares the
time units (ns … day), so a program’s own unit takes another name,
or joins them by what it equals (unit tick = 10 ms;).
Units, end to end walks through a whole program in the
dialect, the mistakes the compiler catches, and hale check --units;
spec/units.md has every rule.
Why these are in the language
Section titled “Why these are in the language”Decimal, Duration, and Time aren’t library types you opt
into — Decimal is a primitive with its own literal, and Duration
and Time are declared by the standard library for every program,
with the time units’ literals. The reason is
that the bugs they prevent (float drift in money, unit confusion
in time) are so common and so costly that making them
first-class is worth it. You get the safety without importing
anything or remembering a convention.
Next: Units, end to end.