hanki

9. Structs and sum types

struct Todo
  id: i32
  text: string
  done: bool
end

type Command
  Add(string)
  Done(i32)
  List
  Quit
end

Sum-type variants are listed one per line, no separator - the same shape as struct fields, actor handlers, and trait items. A variant with no payload (List, Quit) is unit-like. A struct carries at most 255 fields (the runtime object layout's limit); a wider declaration is rejected at check time (H0563).

Construct with positional or keyword arguments:

def build() -> ()
  t = Todo(id=1, text="buy milk", done=false)   # keyword args
  cmd = Add("buy milk")                         # positional variant payload
end

Sum types and structs declare generic parameters in angle brackets after the name, following §11's single-uppercase-letter convention:

type Option<T>
  Some(T)
  None
end

The variant ctor and pattern positions instantiate T per use, so Some(5i32) has type Option<i32> and matching on it binds v as i32. The variant payload itself stays parenthesised - Some(T) is a positional payload list, not type application. Bare generic syntax (<T> / <T, U>) appears on type/struct/actor/trait/def/impl heads; bounded generics (<T: Show>) appear in impl and def heads.

Opaque types - smart constructor pattern

opaque heads its own declaration - a product type (named fields, like a struct) whose construction and field access are private to the defining file. The type name itself stays exported. opaque is a standalone keyword, not a modifier on struct/type; opaque struct, opaque type, and opaque actor are parse errors.

opaque Email
  value: string
end

def parse_email!(raw: string) -> Email
  # validation elided
  Email(value=raw)
end

def address(e: Email) -> string
  e.value
end

Outside email.hk:

Trait impls may be written outside the defining file (impl Show<Email> elsewhere is fine). Such impls can only call exported methods on self; they still cannot reach inside fields or destructure variants. The rule is the Rust/OCaml model: opacity gates construction and destructuring, not behavior extension.

Use opaque when the type's validity is enforced by a smart constructor and you do not want callers to fabricate values that bypass it. The unrelated _ prefix rule (see §12) still applies to per-identifier visibility - opacity is a separate, type-level concept.

where-block invariants on opaque types

An opaque type may declare a where block: a sequence of bool invariants, one per line, that every value of the type must satisfy. This lifts validity from a hand-written smart constructor into the type itself.

opaque Email
  value: string
where
  not value.is_empty
  value.contains("@") else "email must contain @"
end

struct's fields (by bare name - value, not self.value) plus the module's pure functions. A predicate may not reference self (no value exists yet), call an action (name!), require an effect, or reference the type being defined. An optional else "…" supplies the human-facing failure description; otherwise the predicate's source text is used.

Result<Email, validation.ValidationError>**: it checks each predicate in source order and returns Err(ValidationError) (§ stdlib validation) on the first failure, otherwise Ok(value). Validation failure is a value, not a throw (§7) - new is a pure function (no !, no effects), so it stays total. Match it: match Email.new(s)\n Ok(e) -> …\n Err(v) -> …\nend. The error carries a structured snapshot - type_name, predicate_source, the optional label, the constructor args as field_values, and location: the **construction call site** as file:line:col, threaded in by the compiler (the call site passes it invisibly), so a failed validation names where it was attempted on both tiers; Display renders it as … at main.hk:12:9`.

constructs without validating. unchecked is file-local - callable only inside the defining module (the sanctioned bypass for vetted data).

defining module, direct construction Email(value=…) is a compile error directing you to Email.new(…) (validates, returns Result) or Email.unchecked(…) (the file-local bypass). There is no silent rewrite, so a construction's type is never quietly changed.

predicates fold at compile time: a construction that would fail validation is a compile error rather than a runtime Err the caller might ignore (§16). A non-literal argument is left for the runtime check.

is a parse error.

Worked example. examples/v0_1/parse_dont_validate/ demonstrates Parse, Don't Validate and Make Illegal States Unrepresentable end to end: an opaque where-validated smart constructor (the unforgeable parsed value), a state-carrying sum (the payload exists exactly in the variant that owns it), and typestate via two distinct opaque types (so calling an operation in the wrong state is a compile error, not a runtime check) - with a test showing the validated constructor is the only parse path.