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:
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, like adefmethod call. Opacity gates construction and field access, never behavior: apropis mechanically a method and crosses the boundary like any accessor. This completes the smart constructor under the Uniform Access Principle. Expose a validated view as aprop, and 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, 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
- Each predicate is a single
boolexpression resolving in a scope of the struct's fields, by bare name (value, neverself.value), plus the module's pure functions. A predicate may not referenceself, no value existing yet, call an action (name!), require an effect, or reference the type being defined. An optionalelse "…"supplies the human-facing failure description, and without one 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 returnsErr(ValidationError)(stdlibvalidation) on the first failure, andOk(value)otherwise. Validation failure is a value and no throw (§7):newis a pure function, with no!and no effects, and it is total. Match it:match Email.new(s)\n Ok(e) -> …\n Err(v) -> …\nend. The error reports a structured snapshot:type_name,predicate_source, the optionallabel, the constructor args asfield_values, andlocation, the construction call site asfile:line:col, threaded in by the compiler with the call site passing it invisibly. A failed validation therefore names where it was attempted, on both tiers, andDisplayrenders it as… at main.hk:12:9. A stored or passed constructor reports its invocation site: inapply(f, value)whose body callsf(value), that call insideapplyis the construction site, even whenfwas bound elsewhere. - It also generates
Email.unchecked(value: string) -> Email, which constructs without validating.uncheckedis 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 toEmail.new(…), which validates and returnsResult, or toEmail.unchecked(…), the file-local bypass. There is no silent rewrite, and a construction's type is never changed behind you. - Where 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 and no runtimeErra caller might ignore (§16). A non-literal argument is left for the runtime check. - v1 covers non-generic
opaquetypes.whereon a transparentstructis a parse error. - A derived
Decodeimplementation calls.newafter decoding every field. A failed predicate returnsErr(deserializer.InvalidValue(error, offset)), with thevalidation.ValidationErrorfrom.newand the cursor position after those fields. Itslocationidentifies the opaque type declaration. Field or framing errors propagate before validation. The generated validation runs at decode time, including for a fieldless type. Explicit and on-demand derivation follow this rule in every format and on both tiers.
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.