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 with no separator, the same layout as struct fields, actor handlers and trait items. A variant with no payload (List, Quit) is unit-like. A struct has at most 255 fields, the runtime object layout's limit, and a wider declaration is rejected at check time (H0563).

Construct with positional or keyword arguments. A keyword argument is name=value, following §2's named-value rule:

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 constructor and pattern positions instantiate T per use: Some(5i32) has type Option<i32>, and matching on it binds v as i32. The variant payload is parenthesised, Some(T) being a positional payload list and no type application. Bare generic syntax (<T>, <T, U>) appears on type, struct, actor, trait, def and impl heads; bounded generics (<T: Show>) appear in impl and def heads.

Two sums of one module may declare variants with the same name, and sys declares Other in five of its sums. Such a shared name resolves by the type the context demands: the expected type in expression position, an annotated binding, an argument's parameter type or a return position, with the qualified sys.Other(…) spelling resolving the same way, the module prefix naming the module and not the sum; and the scrutinee's type in a pattern (§14). Where nothing supplies a type, the use is ambiguous and rejected (H0624). Declaration order never breaks the tie, and no program can build the wrong sum without a diagnostic. Names shared across modules are the separate open-collision rule (H0306, §14).

A constructor that takes arguments is a value where the context types it. Handing a payloaded variant (Wrap) or a fielded struct (Box) to a combinator, xs.map(Wrap) or xs.map(Box), is ordinary: the parameter type, or the binding's annotation, says which type is being built, as it does for a call. Argument-free constructors are values already, and a unit variant (None, Quit) and a fieldless struct read bare in expression position as they appear in a pattern. What is rejected (H0626) is a bare constructor with nothing to type it, g = Wrap, where the sum is unsaid. A shared name does not resolve by winning a registration either. Where several sums declare it and the context cannot pick one, that is H0624, the ambiguity rule above, and never a silent choice, and a later sum reusing a name turns a working program into an error in place of a different program.

An inherent associated function is a value. xs.map(Colour.tag) is ordinary, as a top-level def is (xs.map(twice)), and an associated action takes its effect row along (xs.each!(Colour.emit!)). The constructor rule does not extend to it, for the reason above: Colour.tag names one function, there is no sum to pick, and nothing for a later declaration to break. What is rejected (H0628) is an associated function reached through a trait impl, i32.parse from impl FromString<i32>. Dispatch settles which function that names, and a bare reference brings no argument types to settle it with. The fix there is the same closure (xs.map(|s| i32.parse(s))) or a helper def. The two codes are distinct because the failures are: Type in Type.name is a type and no value binding, and a resolver searching values alone reports the type as missing, which blames the one part of the program that was right.

Opaque types: the smart constructor pattern

opaque heads its own declaration: a product type with named fields, like a struct, whose construction and field access are private to the defining file. The type name itself is exported. opaque is a standalone keyword and no modifier on struct or 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, and impl Show<Email> elsewhere is fine. Such impls can call only exported methods on self, and they still cannot reach inside fields or destructure variants. This is the Rust and OCaml model: opacity gates construction and destructuring, never behavior extension.

Use opaque where the type's validity is enforced by a smart constructor and callers should not be able to fabricate values that bypass it. The unrelated _ prefix rule (§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.empty?
  value.contains?("@") else "email must contain @"
end

The worked example is examples/v0_1/parse_dont_validate/, which 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, where the payload exists in the variant that owns it; and typestate through two distinct opaque types, where calling an operation in the wrong state is a compile error and no runtime check. A test shows the validated constructor is the only parse path.