Skip to content

Proposal: predicate subtypes for immutable (all-const) structs #220

Description

@ganehag

This is a proposal for discussion, not an implementation plan.

Came out of a conversation about why subtype rejects struct parents today ('Point' is not a named scalar or enum type; a subtype's parent must be one) — this proposes a narrow, principled extension rather than a blanket "structs get predicates too."

Motivation

Predicates already let a scalar named type push a domain rule into the type system instead of runtime validation scattered across constructor functions: type Port int predicate func(x) { return x >= 1 and x <= 65535 } makes "not a valid port" impossible to construct, not just discouraged. Structs have no equivalent. The only tool today is a smart-constructor function (func NewNonEmptyPoint(x int, y int) Point), which is bypassable — nothing stops Point{x: 0, y: 0} from being written directly anywhere else in the program. That's a real, current gap: an invariant a struct's author cares about has no way to become an actual property of the type.

Why this can't be "just add predicates to structs"

Predicates today are deliberately scalar-only — collections (array_t/map_t), enums, and variants are all explicitly excluded (docs/language.md: "They cannot be declared on collection types, enums, or variants"). That's not incidental. A predicate is checked once, at construction/cast time (check_named_predicate, emitted by emitNamedValidation in compiler.zig). For that check to mean anything, the value can't change out from under it afterward — which is true for scalars (arithmetic always produces a new value, re-checked at that point) but is not true for a mutable struct: p.x = 0 reassigns a field in place, with no construct/cast opcode involved at all, silently invalidating any predicate that was only checked at construction. A struct predicate that can be trivially violated one line later isn't a weaker version of the scalar feature, it's a misleading one — code reading type NonEmptyPoint struct {...} predicate ... would reasonably assume the invariant always holds.

The existing scalar-only boundary already has a coherent organizing principle once you look at it this way: predicates apply to values that can't change after construction. Gengo already has a way to make a struct satisfy that: fields declared const are read-only after construction today (type Point struct { const x int, const y int }; p.x = 5 is already a compile error). So the natural scope for this proposal is: a struct may be a subtype/predicate parent only if every one of its fields is const. That's not a new, arbitrary carve-out alongside the existing scalar-only rule — it's the same rule, correctly extended to the one other case where it still holds.

Structs with any non-const field remain excluded, for the identical reason arrays/maps are excluded today — no different treatment, no special case to remember.

Sketch

type Point struct { const x int, const y int }
subtype NonEmptyPoint Point predicate func(p) { return p.x != 0 or p.y != 0 }

a := NonEmptyPoint{ x: 1, y: 0 }   // ok
// b := NonEmptyPoint{ x: 0, y: 0 }  // runtime predicate violation

Current technical gap (why this doesn't work today, concretely)

Checked the actual code before writing this up, not just guessing:

  • NamedTypeBase (value.zig) is {int, float, decimal, string, bool, rune, array_t, map_t, enum_t} — no struct-backed variant, so a named type can't wrap a struct at all today.
  • subtypeDecl's parent lookup (compiler_decls.zig) only ever resolves against that same registry bucket — confirmed via getNamedTypeInfo, which is why a struct parent currently reports "not a named scalar or enum type" rather than being partially accepted.
  • build_struct_instance (vm.zig) has no call into check_named_predicate/emitNamedValidation or any equivalent — the validation hook that scalars get at every cast/construct site simply isn't wired into struct construction anywhere.

None of this is a fundamental blocker, but it's real engineering across the type registry, the named-type runtime representation, and struct construction — not a small patch.

Open questions (not resolved here)

  • Construction syntax. Does NonEmptyPoint{ x: 1, y: 0 } work directly (a struct literal spelled with the subtype's name), or does construction require going through the parent first (NonEmptyPoint(Point{ x: 1, y: 0 }), mirroring how scalar named types are always constructed via TypeName(value))? The former is more ergonomic; the latter reuses the existing "construction is the one explicit adaptation point" model with zero new literal-syntax handling.
  • Field redeclaration. Does the subtype restate its fields (like a fresh type ... struct {...}) or inherit the parent's field list automatically, the way range/cycle/clamp subtypes inherit their base type rather than redeclaring it?
  • Method inheritance. Scalar subtypes inherit the parent's methods transitively. Should a struct predicate-subtype get the same? This is the part I'd be most cautious about — a struct that's a distinct nominal type and inherits methods starts to look like struct inheritance, which Gengo has deliberately avoided in favor of interfaces + composition. Worth considering scoping v1 to no method inheritance (methods called via the parent type only) to sidestep that question entirely.
  • Generic structs. Does Stack[T] ever make sense as a predicate-subtype parent, and does the all-const-fields requirement apply per-instantiation or to the generic declaration itself?
  • Variants. Not proposed here, but the same all-const/immutable-after-construction reasoning would extend cleanly to a variant whose shared and arm fields are all const, if this lands and proves out for structs first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low priorityarea:compilerLexer, parser, compilerenhancementNew feature or requestquestionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions