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:
Email(value="x")is rejected ("cannot construct opaqueEmail").e.valueis rejected ("cannot access field of opaqueEmail").e.domain— apropread (§10) — is allowed from any file, exactly like adefmethod call. Opacity gates construction and field access, not behavior: apropis mechanically a method, so it crosses the boundary like any accessor. This is the Uniform Access Principle completing the smart constructor — expose a validated view as apropand the representation can change without moving the consumer-facing bare-dot surface.- The type name remains usable in signatures, as a binding type (
e: Email), inspawnresults, and on the receiver side of method calls.
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
- Each predicate is a single
boolexpression resolving in a scope of the
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.
- The compiler generates a pure `Email.new(value: string) ->
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`.
- It also generates
Email.unchecked(value: string) -> Email, which
constructs without validating. unchecked is file-local - callable only inside the defining module (the sanctioned bypass for vetted data).
- A where-having opaque type has no bare constructor - even inside the
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.
- When a
Type.new(…)call's arguments are all comptime-known literals, the
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.
- v1 covers non-generic
opaquetypes.whereon a transparentstruct
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.