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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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