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
a subject name declared by subject; its values are that namespace’s ids

Four 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
an event, @order.placed a module fn’s return type

An event is a record rather than a value, so making one is pure and a fn may hand one back, and nothing else may name one. What consumes it is a test’s given and expect.

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(buyer) is Opt(Sealed(String, Customer)). See Personal data.

A subject is nominal to the checker and its scalar to the runtime

subject Customer(Int) makes Customer a type whose values are customer ids, and the id type may be Int, String or Uuid. Every position that asks a structural question of one reads through to that scalar: an entity key, an index column, a map key, a partition key, an ordering, the JSON boundary. Arithmetic and methods do not, because a subject has no row in either table, which is how “an id is an id and nothing else” costs no rule of its own.

A literal resolves against a subject exactly as it resolves against the id type, so buyer: 7 is a customer id by the same rule that makes order_total: 25.99 money. A name does not: an Int in scope cannot fill a Customer, and neither can a const LIMIT: Int, because a literal has no identity of its own to launder and a name does. const HOUSE: Customer = 1 fills one, by its declared type rather than by its value.

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, and a Customer is not a Shop however alike their ids look.

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()
}

&& carries what its halves prove where it is true, and || where it is false. So if a.is_some() && b.is_some() narrows both inside the branch, and if a.is_none() || b.is_none() { reject Missing } narrows both after it. The sides they cannot speak for stay closed: a true || does not say which half made it true, and a false && does not say which half failed, so neither narrows anything. An operand is narrowed by the operands before it, because that is when it runs, which is what makes if x.is_some() && x > y an ordering of a value already proved present.

Three shapes deliberately do not narrow: 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 short 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), a Money(n) or a subject whose ids are one of those, 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. The one width a specifier would have been reached for is a method instead: Int.pad(width) zero-fills on the left, so "{y}-{mo.pad(2)}" writes a two-digit month. See Standard library.

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. A seal carries the subject’s declared name rather than the field the annotation pointed at, so two fields sealed under one subject are the same type and one fills the other’s position