Skip to content
hekla

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 truncate returned, and upper() can grow a count: ß uppercases to SS, so body.truncate(3).upper() may be six characters.
  • It is on String, not String?. On an optional it is no method truncate on String?. Write body.unwrap_or("").truncate(n), or narrow first.
  • A sealed destination is fine, a sealed receiver is not. Writing plaintext into a @subject field is the encrypting direction, so email: email.truncate(200) checks clean. e.email.truncate(200) on content folded out of the log is seal-boundary, the same as e.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 fn is pure, which is the whole of what makes it callable from a command, a projector and a fold arm alike.
  • An effect-local fn may call out but not decrypt and not read a clock, because now() is pinned into a slot the arm fills, and reveal and erase stay 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, no str(), 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, filter or general fold methods. A comprehension covers map and filter, iteration order is already defined, and sum covers the one reduction. A general fold takes an arbitrary arm, and an arbitrary arm over Money is 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_or and narrowing cover it without a panic.
  • No random, no uuid4, no minted identity.
  • No duration type, and no calendar timestamp arithmetic. add_months and add_years are the ones month-end clamping is an argument about, and the calendar fields plus from_parts make them writable as a fn. The fixed-length units have no clamping question, so they are in the language.
  • No regular expressions. contains, starts_with and after_last cover what came up.
  • No Money conversion 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, no break, no continue, no recursion, no mutable bindings, no closures, no generics, no overloading, and no default or named fn arguments.