Skip to main content
v0.13.0
rfc-0032implemented

Field-Level Visibility

Summary

Make struct field visibility independent of the struct's own visibility. Fields are currently all-public when the struct is pub. This RFC proposes making fields module-private by default and requiring an explicit pub annotation to expose them. This is a breaking change for existing pub struct definitions.

Motivation

The current spec states: "Fields of a pub struct are public." There is no mechanism to expose a type's name and API while keeping its internal state hidden. This makes it impossible to enforce invariants on public types — any module can read or write any field directly.

The core problem

// token.mln
pub struct Token {
kind: TokenKind, // can't make this read-only from outside
span: Span, // can't hide implementation detail
offset: Int, // internal implementation detail — exposed anyway
}

Without field-level visibility, Token cannot:

  • Prevent callers from reading offset (an internal detail).
  • Prevent callers from constructing Token directly with arbitrary field values (bypassing validation logic in a constructor function).
  • Expose a stable read-only surface while keeping internal layout flexible.

Cross-language precedent

All major statically-typed languages with module/package systems support field-level visibility:

LanguageDefault field visibilityOpt-in for visibility
RustPrivate to enclosing modulepub on field
Swiftinternal (module-level)private, public, etc.
Kotlinpublicprivate, internal, etc.
JavaPackage-privatepublic, private, etc.
C#privatepublic, internal, etc.

Metel's current all-public-or-nothing model is an outlier. The Rust model (fields private by default, pub to expose) is the right fit given Metel's Rust-inspired syntax and module system.

Proposal

Rule: fields are module-private by default

A field with no visibility annotation is accessible only within the module that declares the struct. A field annotated with pub is accessible from any module that can see the struct type.

pub struct Token {
pub kind: TokenKind, // externally readable
pub span: Span, // externally readable
offset: Int, // module-private implementation detail
}

The struct name (Token) follows its own pub annotation independently. Field annotations are orthogonal.

Visibility matrix

Struct annotationField annotationType accessible externally?Field accessible externally?
pub structpub fieldYesYes
pub struct(none)YesNo
(none) structpub fieldNoNo (struct not nameable)
(none) struct(none)NoNo

A pub annotation on a field of a non-pub struct is syntactically valid but has no effect — the field cannot be accessed because the type itself is not externally nameable. It is not an error (it may become relevant if the struct is later made public).

Construction

Constructing a pub struct with any private field from outside the module is a compile error (T0xxx — new error code in the private-field-access family):

// outside token.mln
let t := Token { kind = TokenKind::Ident, span = s, offset = 0 }; // ERROR: field `offset` is private

The intended pattern is a public constructor function:

// token.mln
pub struct Token {
pub kind: TokenKind,
pub span: Span,
offset: Int,
}

pub fun Token::new(kind: TokenKind, span: Span) -> Token {
Token { kind, span, offset = span.start }
}

From outside the module, callers use Token::new(...). This enforces invariants at the boundary.

Pattern matching

Pattern matching from outside the declaring module must use .. (struct rest pattern) to skip private fields. Explicitly naming a private field in a pattern is a compile error (same error as construction):

// outside token.mln
match token {
Token { kind, span, .. } => { ... } // OK — offset skipped with ..
Token { kind, span, offset } => { ... } // ERROR: field `offset` is private
}

Correction (2026-08-24, metel-core#753/#755). Struct pattern matching was implemented with bare field bindings only — there is no field: subpattern form (this example originally showed kind: TokenKind::Ident, matching a specific value for that field). A field can be bound to a name or skipped via ..; matching one against a nested pattern isn't part of what shipped, and isn't required by this section's actual normative claims (which are about which field names may appear, not about matching a field's own value).

Pattern matching within the declaring module has no restrictions — all fields are visible regardless of annotation.

Exhaustiveness checking: a struct with any private fields cannot be exhaustively matched by an external pattern that does not use ... The compiler must enforce this.

linear struct and linear enum

Correction (2026-08-22). linear struct/linear enum never materialized as a language construct. The linear keyword traces to RFC-0024 (Linear Types); metel-core#753 flagged real ambiguity over whether that lineage was merely unimplemented or dropped outright, given that RFC-0024's superseding RFC (RFC-0028, refused) carries a note that its "foundation layer... linear types... stands and may be implemented." Resolved by RFC-0071 (Ownership and Move Semantics) itself, whose own References section settles it: "RFC-0024 (Linear Types, superseded) — prior exploration of linear/affine ownership in Metel; this RFC is the settled formulation of the same core idea." RFC-0071's accepted, implemented Copy/Drop aspect model is that settled formulation — not a linear type qualifier. There is no linear keyword anywhere in the current grammar, and none is planned under this design. This section is retained as historical record of the RFC's original assumptions, not as a description of implementable behavior.

The same rules apply to linear struct. Linear types are still constructable from outside only if all fields are pub, or via a public constructor function.

Enum struct variants

Struct-variant fields in an enum follow the same rules. Tuple-variant fields are positional and cannot be individually annotated; if the enum is pub, those tuple-variant fields are public as part of the public variant shape.

pub enum Shape {
Circle { pub radius: Float }, // radius is public
Rect { pub width: Float, height: Float } // width public, height private
}

Breaking change scope

This RFC changes the default for fields of pub struct from implicitly public to module-private. Every existing pub struct that expects external field access must be updated to add pub on those fields. The compiler must emit a clear migration error.

Because Metel is pre-1.0, this breaking change is acceptable. The CHANGELOG for the target version must document it.

Alternatives Considered

A — Additive priv keyword (non-breaking)

Fields inherit the struct's visibility by default; a priv keyword restricts a field to module-private:

pub struct Token {
kind: TokenKind,
span: Span,
priv offset: Int, // restricted
}

Pros: Non-breaking; no migration needed.

Cons: Unusual — virtually no language uses this model. The default (public fields) is the wrong default for encapsulation. Developers unfamiliar with this choice will accidentally expose fields they intended to keep private. The long-term design pressure will be toward the Rust default anyway; accepting this option defers the breaking change, not eliminates it.

Verdict: Rejected. The breaking change at pre-1.0 is the correct time to establish the right default.

B — Type sealing (OCaml private type)

Rather than per-field visibility, seal the struct's constructor: the type is visible and field values are readable, but external code cannot construct a value of the type using struct-literal syntax:

sealed pub struct Token {
kind: TokenKind,
span: Span,
offset: Int,
}

All fields remain readable; only construction is blocked.

Pros: No per-field annotation clutter; read access always works; simple mental model.

Cons: Does not support hiding fields (callers can still read offset). Does not support partial exposure (some fields public, some private). Less granular than the proposed design. Adding read-hiding later would still require per-field annotations.

Verdict: May be useful as a complementary feature alongside field visibility (a sealed type with all-public fields provides a constructor barrier). Not a replacement. Deferred.

C — Getter/setter asymmetry (Swift / Kotlin model)

Allow a field to have a public getter and a private setter:

pub struct Counter {
pub(set: priv) count: Int,
}

Pros: Expressive; common pattern (read-only external access).

Cons: Adds syntax complexity; requires understanding of "properties" vs. raw fields. The same effect can be achieved today with a private field and a public accessor function. Metel does not yet have computed properties, making this premature.

Verdict: Deferred until computed properties or properties-as-first-class-syntax are designed.

D — Module-path scoped visibility (pub(super), pub(in path))

Add scoped visibility modifiers mirroring Rust:

pub(super) kind: TokenKind, // visible to parent module
pub(in parser) span: Span, // visible within parser module subtree

Pros: Very fine-grained access control; enables crate-internal APIs.

Cons: High complexity; Metel's current module system has no crate-equivalent scope. pub vs. module-private is sufficient for the stated motivation. Can be added later without a breaking change.

Verdict: Deferred. The two-level system (pub / private) is sufficient for v1.0 scope.

Resolved Decisions

D1 — Private-field access uses one error code family

Constructing a struct with a private field from outside the declaring module and pattern-matching a private field from outside the declaring module are treated as the same language error category: private field access across a module boundary. The diagnostic text may distinguish construction from pattern matching, but the spec and error-code table should treat them as one error family.

D2 — No per-field visibility on tuple variants

This RFC does not introduce per-position visibility syntax for tuple variants. If an enum is pub, its tuple-variant fields are public as part of that public variant shape. Struct-variant fields continue to use named-field visibility annotations.

D3 — Inert pub on a field of a non-pub struct should warn

When a field is marked pub but the enclosing struct is not public, the compiler should emit a warning rather than silently accepting the inert annotation. This is a developer-intent issue, not a language error.

D4 — Spec changes are part of acceptance work

Accepting this RFC requires a same-change spec update in docs/public/reference/spec/modules.md, including:

  • replacing the blanket "fields of a pub struct are public" rule
  • documenting construction restrictions for private fields
  • documenting pattern-matching restrictions for private fields

Timing Recommendation

This RFC should be resolved before any feature that adds new struct-based types to std::core or the standard library — once public structs with all-public fields proliferate in shipped library code, the migration cost grows.

The earliest sensible target is the sprint that first adds library types with private implementation details. It should not block v0.6.x or v0.7.0 unless those versions introduce such types.

References

  • Language spec: docs/public/spec/modules.md — Visibility section
  • Implementation report: metel-interpreter/docs/module-system-report.md §7.4 (Field-level visibility)
  • GitHub issue: #468
  • Rust reference: Visibility and Privacy
  • Swift: Access Control
  • OCaml: Private Types

Decision

Outcome: Accepted Target: (pending milestone assignment)

The field-visibility design and its remaining language-shape questions are resolved. Follow-up work is spec alignment and later implementation planning.

Coverage Checklist (added 2026-08-19, not part of the original RFC)

Retroactive breakdown of this RFC's distinct, fixture-testable normative claims (expanded 2026-08-19: added item 8, missed in the original pass), as headed sections for citation purposes only. The document above is unchanged and remains the historical record. Deliberately excludes claims that aren't independently observable from a program's behavior -- implementation strategy, design rationale, or internal architecture discussion belongs in the RFC's own prose, not here.

1. Struct fields are module-private unless declared public

Field visibility is independent of the enclosing struct's visibility. A public field of a public struct may be accessed from another module, while an unmarked field may not.

2. Private fields cannot be read or assigned across modules

Reading or assigning a private field outside its declaring module is rejected with the private-field visibility error T0009. The declaring module retains access to all of its fields.

3. External construction requires every named field to be visible

Constructing a struct literal outside its declaring module is rejected if it names any private field. A module-local constructor or helper may construct the value instead.

4. External patterns may not name private fields

A pattern outside the declaring module cannot explicitly bind a private field and must use .. to omit private fields. A pattern in the declaring module may name all fields.

5. Private fields prevent externally exhaustive named-field patterns

An external named-field pattern for a struct with private fields must include ..; otherwise it is not a permitted exhaustive representation of that struct.

6. Field visibility also applies to enum struct variants

Named fields of enum struct variants use the same public/private rules as ordinary struct fields. Public enum tuple-variant positions remain exposed as part of their positional variant shape.

Note (2026-08-22): this item originally also claimed the same for linear struct/linear enum, which don't exist in the language — see the correction on the RFC's own "linear struct and linear enum" section above. Dropped from this item; it is not part of what remains to be tested here.

7. Public fields on a private struct do not expose the struct externally

Marking a field public does not make its enclosing private struct nameable from another module, so that field remains inaccessible through the private type.

8. A public field on a private struct produces a warning

The compiler warns when a field is declared public on a struct that is not public, because the field cannot be accessed across a module boundary.