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.

Public fields are accessible through values obtained from another module, including nested values such as row.target.revision. The declaring module needs no import for these reads. Naming its type or constructor follows the import rules of §14. Opaque and private fields retain their access restrictions.

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.

An opaque type has only the trait impls written or @derived for it. On-demand derivation (§16) never synthesises one, in any file, the defining file included. A derived impl builds the value or reads its fields, and which traits are derived for the type is its author's decision, made with @derive in the defining file. A use that needs an impl nobody wrote, ==, #{…}, .encode!, Type.decode! or a T: Trait bound, at the opaque type or through a field of another type, is H0405 naming the opaque type.

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.