Skip to content
hekla

Types and values

The complete type table, why equality is exact, and the one rule for what may be written where.

heklang’s type system is small and has no escape hatches. There is no null, no coercion, no subtyping, and no way to spell a value that is not one of the shapes below.

The types

Spellable anywhere a type is written:

Type
Bool
Int 64-bit signed
Decimal(n) a scaled decimal, n places, a rate
Money(n) a scaled integer, n places, an amount
String bounded with @max(n) where it is stored
Uuid
Timestamp UTC, RFC 3339 on the wire
Json an opaque document, read with accessors
List(T)
Map(K, V) K must be a type that orders
T? the only absence in the language
an enum name
a record name

Three types exist but cannot be written in a general type position, so none of them can appear inside a List, a Map or a record:

Type Where it can appear
Response a fn signature
Outcome a fn signature
Secret an effect-local fn signature

Rounding is spellable nowhere at all. It reaches .mul and .div as the bare words HalfUp, HalfEven and Down.

And one type is derived rather than written: Sealed(T, subject), produced by putting @subject(...) on an event field. Opt is always outermost, so String? @subject(x) is Opt(Sealed(String, x)). See Personal data.

Equality is exact and structural

Money(2) is not Decimal(2). Money(2) is not Money(3). Content sealed under one subject is not content sealed under another.

There is no widening, no numeric promotion and no subtyping. An Int does not become a Decimal(2) because the context wanted one.

The fill relation

One rule decides what may be written where. A value of type F fills a declared T when:

  1. F is T; or
  2. T is F?, so a bare value wraps into an optional; or
  3. T is F sealed, so plaintext goes into a subject-bound field.

Nothing else. Two consequences catch people:

Wrapping is one level, at the outside. List(String) does not fill List(String?).

T? does not fill T. An optional has to be narrowed or unwrapped first.

The relation applies at every declared position: a command argument, an emit field, an entity column, a fn parameter, a return, a record field.

Optionals

T? is the only absence. There is no null, no empty-string-means-nothing, and no zero-means-nothing.

Method Returns
unwrap_or(T) T
is_some() Bool
is_none() Bool

There is no unwrap and no expect, because narrowing removes most of the calls you would otherwise write:

if email.is_some() {
  // email is a String here, not a String?
  let clean = email.trim()
}

Four shapes deliberately do not narrow: a compound condition, the value-position if, an else if, and an equality against a bare value. Each is sound in principle, and left out to keep the rule three lines instead of a paragraph of exceptions.

Comparing an optional to a bare value works, because == and != lift the bare side. Ordering one with < is an error.

Money

Money(n) is a distinct type from Decimal(n), and the operator table is the whole reason:

Expression Result
Money(n) + Money(n) Money(n)
Money(n) - Money(n) Money(n)
Money(n) / Money(n) Decimal(6)
Money(n) * Money(n) error
Money(n) + Decimal(s) error
amount.mul(rate, Rounding) Money(n)
amount.div(Int, Rounding) Money(n)

An amount times a rate is an amount. An amount over an amount is a rate. An amount plus a rate is a compile error, and an amount times an amount is meaningless.

.mul and .div are the only two places money rounds, and the author says how. The result keeps the amount’s scale; the rate carries its own, so total.mul(0.9, HalfUp) takes a Decimal(1).

Currency is not in the type, the value, or the configuration. A scale is a storage floor, not a currency, and scale caps at 18. If you need currency, declare an ordinary field beside the amount.

Literals

The lexer emits one untyped number per literal. There is no suffix and no constructor: a literal becomes an Int, a Decimal(s) or a Money(n) depending on where it lands.

Target 10.5 10
Int error 10
Decimal(2) 10.50 10.00
Money(2) 10.50 10.00
Money(0) error 10

Widening is exact: a literal with more places than the target holds is an error rather than a silent round.

Where the target comes from is where the type comes from, and a literal with no target takes its default: scale 0 is an Int, any other scale is a Decimal(n) at the scale written. Nothing ever defaults to Money, so an amount is always reached from a declaration.

Strings

Interpolation is the whole mechanism:

log("order {order_id} for {customer_id}")

A hole holds an arbitrary expression, not just a name, because the lexer nests. \{ writes a literal brace.

A raw string takes no escapes and no interpolation:

let body = """
{"kind": "raw", "braces": "kept"}
"""

There is no + on strings, no str(), no .to_string() and no format specifiers. A value’s text form is its JSON form, and that table is fixed.

What is not checked

Being honest about the edges:

  • anything whose type came out unknown, because one mistake should stay one diagnostic
  • a fn body against its callers, beyond the signature
  • the shape of a Json
  • currency
  • two values sealed under different subjects, beyond that they differ