Standard library
Everything callable an author did not declare, and which declaration kinds may call each one.
The callable surface is small on purpose, and this is the whole of it. A method that is not in these tables does not exist, and the error names the receiver.
There are no modules and no namespacing. The surface is methods keyed by receiver type, plus constructors named by the type they build.
Methods by receiver
String
| Method | Returns | |
|---|---|---|
trim(), lower(), upper() |
String |
|
len() |
Int |
|
is_empty() |
Bool |
|
contains(s), starts_with(s) |
Bool |
|
strip_prefix(s) |
String |
unchanged when the prefix is absent |
after_last(s) |
String |
the whole string when the separator is absent or empty |
truncate(n) |
String |
the first n characters, or the whole string when it already fits |
to_int() |
Int? |
|
to_uuid() |
Uuid? |
strip_prefix is written after a starts_with that already decided, and after_last is
total so gid.after_last("/") is safe on something that is not a global id. The two
conversions are optional because there the failure is the point.
truncate counts characters, the unit len() reports and @max(n) bounds, so
text.truncate(n) satisfies @max(n) for every input and a count at or below zero gives
"". It is how a value from outside is made to fit a bounded field. @max never truncates
on its own, because silent truncation at a schema edge is the string version of a silent
round.
put OrderNote { order_id, note: "note: {notes}".truncate(80) }
A projector is where it matters most, because a projector has no failure channel and an
over-long value fails hard there. A truncated value is computed rather than read, so
max-tightening does not fire on it.
Four things to get right. The middle two are compile errors; the first and last are yours to match by hand:
- Truncate last. The guarantee covers what
truncatereturned, andupper()can grow a count:ßuppercases toSS, sobody.truncate(3).upper()may be six characters. - It is on
String, notString?. On an optional it isno method truncate on String?. Writebody.unwrap_or("").truncate(n), or narrow first. - A sealed destination is fine, a sealed receiver is not. Writing plaintext into a
@subjectfield is the encrypting direction, soemail: email.truncate(200)checks clean.e.email.truncate(200)on content folded out of the log isseal-boundary, the same ase.email.trim(). - Nothing checks a literal count against a literal bound.
body.truncate(80)into@max(8)checks clean and then fails at run time. Match the two by hand.
Json
| Method | Returns |
|---|---|
string(key) |
String? |
int(key) |
Int? |
bool(key) |
Bool? |
json(key) |
Json? |
array(key) |
List(Json)? |
number(key) |
String?, the exact text of a JSON number |
Every one is optional: a missing key and a key holding the wrong shape both answer none,
so you write one branch rather than two. int answers none for 10.5.
number returns text because a Decimal or a Money needs a scale, and the scale belongs
to the target rather than to the digits on the wire.
There is no get, no dynamic field access and no indexing.
T?
| Method | Returns |
|---|---|
unwrap_or(T) |
T |
is_some(), is_none() |
Bool |
Three, and there is no unwrap and no expect. Narrowing removes most of the calls you
would otherwise write.
List(T)
| Method | Returns | |
|---|---|---|
first() |
T? |
|
push(T) |
List(T) |
|
concat(List(T)) |
List(T) |
|
remove(T) |
List(T) |
|
contains(T) |
Bool |
|
len() |
Int |
|
is_empty() |
Bool |
|
sum() |
T |
where T + T is a T: Int, Decimal(n), Money(n) |
push, concat and remove build a new list rather than mutating one. remove removes
every equal element, so it is idempotent. concat is push’s other half, a whole list
where that takes one element, and the element types have to agree exactly: a List(String)
does not join a List(String?). There is no indexing.
sum is the one reduction, and its row is read off the operator table rather than kept
beside it, so a type with no + has no sum and the error names the receiver. The element
type is the result type, which leaves no rounding question to ask: the scale in is the scale
out, an empty list is that type’s zero rather than a none, and an overflow is an error
exactly as it is for +. That is also why it is a method rather than a general fold,
whose arbitrary arm is where an amount learns to round.
Map(K, V)
| Method | Returns |
|---|---|
get(K) |
V? |
set(K, V) |
Map(K, V) |
remove(K) |
Map(K, V) |
contains(K) |
Bool |
keys() |
List(K) |
values() |
List(V) |
len() |
Int |
is_empty() |
Bool |
keys() and values() come back sorted by key, which is load-bearing for rebuild and
replay determinism.
Money(n)
| Method | Returns |
|---|---|
mul(Decimal(s), Rounding) |
Money(n) |
div(Int, Rounding) |
Money(n) |
The two places money is allowed to round, and the author says how. Rounding is HalfUp,
HalfEven or Down, written as a bare word.
The result keeps the amount’s scale; the rate’s own scale is whatever you wrote, so
total.mul(0.9, HalfUp) takes a Decimal(1).
Int
| Method | Returns | |
|---|---|---|
pad(width) |
String |
zero-filled on the left; the number’s own text when it already fits |
The only Int method, and it is about text rather than arithmetic, because the arithmetic is
the operators. Interpolation has no format specifiers, so "{y}-{mo}" writes 2026-9 and a
billing-period key comes out a character short for nine months of the year.
"{y}-{mo.pad(2)}" is the shape it exists for, which is why the receiver is the number: a
String.pad_start would make a two-digit month a nested interpolation.
The sign comes first and the zeros after it, and width counts the whole rendering, so
pad(3) answers three characters on either side of zero. A width at or below zero pads
nothing. It is truncate’s pair: one bounds a string above and one bounds it below, and both
count the characters len counts.
A width past 4096 is a runtime error. pad is the only method that makes a string longer
and its width is an ordinary expression, so without a bound a width that reached a program
from a request parameter could take the process down. 4096 is the widest @max a real
declaration carries, and the error says so rather than blaming arithmetic. Nothing checks a
literal width against a literal bound either, exactly as with truncate: id.pad(20) into
an @max(8) column is a compile-clean way to fail where the value is written.
Timestamp
| Method | Returns | |
|---|---|---|
year(), month(), day(), hour(), minute(), second() |
Int |
UTC |
add_seconds(n), add_minutes(n), add_hours(n), add_days(n) |
Timestamp |
n may be negative |
The calendar fields exist so that calendar arithmetic is written as a fn, because
month-end clamping is one opinion among several and the language should not pick yours.
The fixed-length units are in the language, because they have no opinion to defer: a minute
is sixty seconds wherever it lands and a UTC day is twenty-four hours, so there is nothing to
clamp. add_months and add_years are exactly the ones the argument is about, and they
stay absent; the refusal names the four that are here.
A Timestamp is epoch microseconds, and three things follow. add_minutes(0 - 30) goes
backwards, so there is no sub_ family to remember. Sub-second precision survives, where
Timestamp.from_parts is on the second and says so. And an out-of-range result is an
overflow error rather than an optional, the answer Int and Money arithmetic already
give, because five minutes after a real moment is always a real moment.
There is no duration type and no format. A moment becomes text through interpolation.
Outcome
| Method | Returns |
|---|---|
ok() |
Bool |
code(), message() |
String? |
refused(<RefusalName>) |
Bool |
refused declares a String, and a bare refusal name resolves to its code there, so writing
the name makes a rename a compile error rather than a branch that stops matching. A literal
is accepted too, and nothing checks it. An invalid carries no code, so it is refused by
nothing.
Outcome is spellable in a fn parameter and return type and nowhere else. It is what an
invoke comes back with, and what a fn that decides a refusal on a command’s behalf
declares. Nothing constructs one: such a fn writes the same bare reject or invalid a
command writes, and the answer travels the result channel every other fn result travels.
Response
.status is an Int and .body is a Json, without parentheses. This is the only
parenless field access on a builtin type in the language.
Constructors
The global namespace is closed: anything built from nothing is named by its type.
| Call | Returns | |
|---|---|---|
Uuid.derive(seed, name) |
Uuid |
a pure function of both arguments |
Json.empty |
Json |
|
Json.encode(value) |
String |
|
Map.empty |
Map(K, V) |
the type comes from the target |
Timestamp.parse(text) |
Timestamp? |
RFC 3339, offset required |
Timestamp.from_parts(y, mo, d, h, mi, s) |
Timestamp? |
optional, because Feb 30 is not a date |
Money.parse(text) |
Money(n)? |
the scale comes from where the result lands |
Decimal.parse(text) |
Decimal(n)? |
the same, for a rate |
There is no List.empty, because [] already writes it, and no Map literal, because
{ ... } is a JSON object.
reject <Name> and invalid "<message>" are not here, because they build nothing. They are
statements that say what the declaration answers, and saying the answer ends it.
Money.parse widens exactly: "10.5" into Money(3) is 10.500, and "1.2345" into
Money(3) is none.
There is no uuid4() and no random. Uuid.derive is the whole identity story, and it
is pure in both arguments, so a retry and a replay produce the same id.
What a handler does
| Call | Returns | Journaled |
|---|---|---|
http.get(url) |
Response |
yes |
http.post(url, body), .put, .patch |
Response |
yes |
http.delete(url) |
Response |
yes |
invoke Name { ... } |
Outcome |
yes |
now() |
Timestamp |
yes, pinned once per invocation |
erase(value) |
nothing | yes |
log(message) |
nothing | no |
fail "<message>" |
nothing, terminal | n/a |
reveal(value) |
the sealed type, unsealed | no, re-decrypts every attempt |
erase is the one call whose meaning is entirely in its argument: it takes a value whose
type is a declared subject, and that type is the key namespace it destroys. A Uuid, an
Int and a bare literal are all refused. See Personal data.
Every http verb takes an optional trailing headers = { ... }. There is no timeout clause
and no timeout key: one attempt is capped at 10 s to connect and 30 s overall, both fixed in
the runtime.
Where each may be called
The pure surface above is available everywhere. What reaches the world is not.
| command | guard | projector | effect arm | effect fn |
module fn |
fold arm | |
|---|---|---|---|---|---|---|---|
now() |
yes | no | no | yes | no | no | no |
http.* |
no | no | no | yes | yes | no | no |
invoke |
no | no | no | yes | yes | no | no |
log, fail |
no | no | no | yes | yes | no | no |
reveal, erase |
no | no | no | yes | no | no | no |
emit |
yes | no | no | no | no | no | no |
put/patch/update/delete |
no | no | yes | no | no | no | no |
reject, invalid |
yes | yes | no | no | no | with -> Outcome |
no |
Four rules do all of that work:
- A projector has no clock and no network, because a rebuild has to reproduce every value it wrote.
- A command has a clock but cannot call out, because only an effect journals a call.
- A module
fnis pure, which is the whole of what makes it callable from a command, a projector and a fold arm alike. - An effect-local
fnmay call out but not decrypt and not read a clock, becausenow()is pinned into a slot the arm fills, andrevealanderasestay in the arm for the erase-last analysis.
No fold arm calls any of them. A fold is the definition of a state variable, and a definition that reached the network would be a different value on every read.
A test calls none of them. It states inputs and expectations; given, respond and
erased script the world instead.
Deliberately absent
Each of these is a decision rather than a gap.
- No
+on strings, nostr(), no.to_string(), no format specifiers. Interpolation is the whole mechanism and the JSON text table is the whole text form.Int.pad(width)is the one thing a specifier would have been reached for that the table cannot say, and it is a method for exactly that reason. - No
sort,map,filteror generalfoldmethods. A comprehension covers map and filter, iteration order is already defined, andsumcovers the one reduction. A generalfoldtakes an arbitrary arm, and an arbitrary arm overMoneyis where an amount learns to round. - No set type and no tuple type.
Map(K, Bool)covers membership and a record covers two values that travel together. - No
x.expect("reason").unwrap_orand narrowing cover it without a panic. - No random, no
uuid4, no minted identity. - No duration type, and no calendar timestamp arithmetic.
add_monthsandadd_yearsare the ones month-end clamping is an argument about, and the calendar fields plusfrom_partsmake them writable as afn. The fixed-length units have no clamping question, so they are in the language. - No regular expressions.
contains,starts_withandafter_lastcover what came up. - No
Moneyconversion and no rate type. Both need currency back in the type, and currency is deliberately not in the type. Declare an ordinary field beside the amount. - No
while, nobreak, nocontinue, no recursion, no mutable bindings, no closures, no generics, no overloading, and no default or namedfnarguments.