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:
FisT; orTisF?, so a bare value wraps into an optional; orTisFsealed, 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
fnbody 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