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 {
return 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.
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. A subject-bound value is recovered from its schema path, and a record reached
through a container has no path to recover it from. Personal data belongs on the event
field.
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
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.
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.