Skip to content
hekla

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.