Functions, records and constants
The declarations that exist so a rule is written once, and the two places purity is enforced.
Functions
A module-level fn is pure. It takes typed parameters, declares a return type, and every
path returns:
fn line_total(unit: Money(2), quantity: Int) -> Money(2) {
return unit.mul(quantity, HalfUp)
}
Purity is the whole of what makes it callable from a command, a projector, an effect arm and a fold arm alike. A function that could read a clock would be a function a projector could not use.
A fn may decide a refusal by returning an Outcome?, where none means no objection:
fn check_limit(total: Money(2)) -> Outcome? {
if total > MAX_ORDER {
reject TooLarge
}
return none
}
A bare -> Outcome is legal too, when every path is a reject or an invalid. It simply
cannot express the no-objection path.
Response may be a parameter type, which is how response handling is shared between arms.
Effect-local functions
An fn declared inside an effect may call out, and may return nothing:
effect ConfirmBooking {
fn notify(url: String, body: Json) {
let response = http.post(url, body)
if response.status >= 400 {
log("notify failed with {response.status}")
}
}
}
It may not reveal, erase or read the clock with now(), it declares no fold of
its own, and it cannot be called from a fold arm. Keeping reveal and erase in the arm is
what lets the checker prove erasure happens last, and now() is pinned once per invocation,
so read it in the arm and pass the moment in.
A fn may return an event
An event is a record rather than a value. Making one is pure, so a module fn may hand one
back:
// One log line, three cases. A `fn` may return an event, so the shape every case needs is
// written once and the block after a call says what this one is about.
fn an_order(order_id: Uuid, total: Money(2)) -> @order.placed {
return @order.placed {
order_id,
customer_id: 7,
email: "ada@example.com",
ship_to: Address { line1: "1 High St", city: "Reykjavik", country: "IS" },
total,
}
}
@order.placed is spellable in a module fn’s return type and nowhere else: not a
parameter, not inside a List or a Map, not an entity column, and not an effect-local
fn, whose result no test could reach. The literal is written after a return and nowhere
else, so nothing holds an event in a let. What consumes one is a test’s given and
expect. See Tests.
What is rejected
Recursion, statically, with the cycle named. No closures, no generics, no overloading, and no default or named arguments.
Records
A record is a product type declared at module scope:
record Address {
line1: String @max(120),
city: String @max(80),
postcode: String @max(16),
}
Every field must be given when one is constructed. There is no partial record.
Two deliberate absences:
No record update. There is no base with { city: "Reykjavik" }. Building the new value
explicitly keeps each write to the aspect the event carries, so nothing read-modify-writes a
record it did not build.
@subject is illegal on a record field, and it is a dedicated error rather than a
generic one. The annotation names a sibling field holding the id, and a field inside a record
has no sibling to name.
That is narrower than it sounds. A record still carries personal data, because @subject on
an event field whose type is a record seals the whole record:
ship_to: Address @subject(customer_id) is one sealed field rather than nine, reveal hands
back an Address, and @absent keeps working inside it, because a seal is read as stored
history rather than as a request body. See Personal data.
Enums
enum Status {
@default Pending,
Confirmed,
Cancelled,
}
The @default variant is the zero value, which is what makes an enum column usable with
patch. A projector may declare its own enum, and a local one shadows a module-level one of
the same name.
Variants are written bare where the type is known: status: Confirmed.
Constants
// How many orders a shop may take during the launch. Small enough that the example's
// tests can reach it.
const LAUNCH_ALLOCATION: Int = 3
A const holds a literal or a literal aggregate, and may name another const. A cycle
between them is a compile error naming the cycle.
An optional const is either none or a value.
Constants are inlined, so a change to one is a change to every declaration that reads it,
and hekla plan will report those declarations as changed.
Secrets
A secret is a soft word, and the declaration carries no type and no value:
secret DISCORD_WEBHOOK
secret SENTRY_DSN?
The trailing ? marks it optional, so serving starts without it.
The value arrives at deployment, never from the source tree. Its type is Secret, which is
a taint: it can be handed to an outbound call and cannot be interpolated into a log line, a
refusal message or a fail. See Keys and secrets.
A const fills a position by its declared type rather than by the value it holds, and
that is load-bearing rather than a technicality: const HOUSE: Customer = 1 fills a
Customer and const LIMIT: Int = 1 does not, though both inline to the same 1. It is
also the only way to write a named subject id.
Subjects
A subject is a soft word too, and the declaration names a key namespace and the type its
ids are:
subject Shop(Int)
subject Customer(Int) under Shop
The name becomes a type whose values are that namespace’s ids, and under says whose keys
this one’s are wrapped inside. Unlike everything else on this page a subject is global
rather than module-scoped, because a module-local key namespace would mean one person with
two spellings. See Personal data.
Modules
A file is a module, and a module is not a namespace. There is no import syntax, no manifest
and no header item; every .hk file under the project root is part of one program.
Six kinds get their own name space, so a command and a projector may share a name:
command, projector, effect, fn, guard, refusal.