// SPDX-License-Identifier: MIT OR Apache-2.0

Field visibility guide (priv modifier)

Status: ACTIVE since 2026-06-02 (Plan 124.1-124.5). Spec: D220 / D221 / D222 (see spec/decisions/02-types.md).

This guide covers Nova’s per-field privacy system — when to use priv, how it composes with other modifiers, tool support, and comparison with mainstream languages.


1. TL;DR

export type Account {
    ro name str               // public, immutable
    priv mut balance f64      // private, mutable (only Account methods can touch it)
}

export fn Account.new(n str) -> Account =>
    { name: n, balance: 0.0 }

export fn Account @deposit(amount f64) {
    @balance = @balance + amount    // OK — inside Account method
}

// External code:
ro acc = Account.new("alice")
ro n   = acc.name           // ✅ public
ro b   = acc.balance        // ❌ E_PRIV_FIELD_READ
acc.balance = 100.0         // ❌ E_PRIV_FIELD_WRITE

Default visibility is public (consistent with Go’s exported-by- case-style fields and Kotlin/Swift defaults in 92.4% of API surface, as measured on kubernetes API types). Opt-in priv per field when you need invariant protection.


2. When to use priv

Use caseRecommendation
Invariant-bearing internal state (balance, lock-state, cursor pos)✅ Mark priv
Mutable internal cache (mut last_modified)✅ Mark priv
Sensitive data (auth tokens, crypto keys, raw pointers)✅ Mark priv
Public API surface (record DTO fields, config struct values)❌ Leave public
Data-bag types (event payloads, log records)❌ Leave public

Rule of thumb: if a method must validate or coordinate before mutating, mark the underlying field priv. Otherwise, public keeps the surface minimal.


3. Syntax + composition

3.1 Per-field modifier

Visibility modifier first, then mutability, then name, then type:

priv mut money f64       // private + mutable
priv ro id u64           // private + read-only
priv consume token Token // private + consume (Plan 100.x)

priv ordered before mut/ro/consume matches Plan 108 D175/D176

  • Plan 114 D184 modifier-ordering precedent.

3.2 Mutual exclusion: priv vs pub

priv pub x f64    // ❌ E_PRIV_PUB_CONFLICT
pub priv x f64    // ❌ E_PRIV_PUB_CONFLICT (detected at parser)

pub is reserved for an explicit public override of a type-level priv default (Plan 124.7 — type-level flip syntax type X priv {}).

3.3 Named tuples (Plan 124.4 / D222)

Per-field priv extends to named-tuple form (Plan 120 D215):

type Vec3(priv x f64, priv y f64, priv z f64)
type Account(priv balance f64, name str)      // mixed
type Secret(pub key str, priv salt []u8)      // explicit pub

3.4 Generic types (D220 §G1)

Uniform enforcement:

type Stack[T] {
    priv mut len int
    ro capacity int
}

export fn Stack[T] @push(x T) {
    @len = @len + 1     // ✅ inside method scope (recv = Stack)
}

// External:
mut s = Stack[int].new(10)
s.len = 0    // ❌ E_PRIV_FIELD_WRITE — uniform for every T

4. Diagnostic codes (Plan 50 D102 format)

CodeSiteWhen
E_PRIV_FIELD_READMember access to a priv field outside its scopeacc.balance
E_PRIV_FIELD_WRITEMutating assignment outside scopeacc.balance = 0
E_PRIV_FIELD_INITRecord literal or named-tuple ctor outside scopeAccount { balance: 0 } or Vec3(x: 1.0)
E_PRIV_FIELD_PATTERNPattern destructure outside scopeAccount { balance } = acc
E_PRIV_FIELD_INIT_SPREADRecord literal spread outside scopeAccount { ...other }
E_PRIV_PUB_CONFLICTBoth priv and pub on same fieldpriv pub x f64

Each diagnostic includes:

  • Spec link (D220 / D221 / D222)
  • Hint suggesting public method or factory
  • Span on the violating site

5. Tooling

5.1 nova doc

Default — priv fields hidden from rendered docs:

$ nova doc src/account.nv
type Account { name str }    # balance hidden

Use --include-private to show all fields (with priv keyword preserved in rendered signature):

$ nova doc src/account.nv --include-private
type Account { name str; priv mut balance f64 }

JSON output (--format json) emits "priv_field": true for each priv field, regardless of --include-private — consumed by LSPs and other tooling.

5.2 LSP (forward-ref)

When Plan 104.2 (hover) and Plan 104.3 (completion) land, they will:

  • Hide priv fields from autocomplete outside type-method scope.
  • Show 🔒 priv badge in hover popups.
  • Display priv-field code-lens decorations.

The AST RecordField.priv_field and NamedTupleField.priv_field flags (already exposed) are the data source. Plan 124.5 V1 wires the doc layer; LSP integration follows once Plan 104.2/104.3 ship.

5.3 No reflection backdoor

Nova has zero reflection API (D6 managed GC + AOT codegen). priv enforcement is compile-time, hard guarantee.

Compare with Java/Kotlin/C#/Swift, which all have reflection APIs that bypass private (privileges aside) — Nova’s guarantee is stricter than any of them.


6. Comparison vs other languages

CapabilityGoRustTSJavaSwiftC#Nova
Per-field privacy❌ (case-based)pubprivateprivateprivateprivatepriv
Default visibilitypkg-priv if lowercasemod-privpublicpackageinternalprivatepublic, opt-in priv
Strict type-only scope❌ (pkg-wide)❌ (mod-wide)✅ (class)✅ (class)❌ (file/mod)✅ (class)type-method-only
Reflection backdoorcompile-time enforced
Forced factory for priv-initpub(...)outside-scope blocked
Tuple field privacystruct(pub T)named tuple priv

Nova matches or exceeds on 6/6 capabilities + 3 Nova-only superior guarantees (strictest scope, no reflection, integrated with the effect system D2).


7. Migration

V1-V5 are purely additive — existing code (no priv modifier) unchanged, compiles bit-identical to pre-Plan 124.

Plan 124.6 (test access escape) and 124.7 (type-level flip) add opt-in features without breaking V1-V5 semantics. Edition flip considered but rejected in favor of per-type type X priv {} flip (kinder migration story).


8. Common patterns

8.1 Invariant-preserving setter

export type Account {
    ro id str
    priv mut balance f64
}

export fn Account mut @deposit(amount f64) -> () {
    assert(amount >= 0.0, "deposit must be non-negative")
    @balance = @balance + amount
}

export fn Account @balance_of() -> f64 => @balance

8.2 Cache via priv mut

export type ParseCache {
    ro source str
    priv mut last_parse Option[Ast]
}

export fn ParseCache mut @parse() -> Ast {
    if Some(a) = @last_parse {
        return a
    }
    ro a = do_parse(@source)
    @last_parse = Some(a)
    a
}

8.3 Sensitive data with priv ro

export type Session {
    ro user_id str
    priv ro token str          // immutable + private
}

export fn Session.from_login(uid str, t str) -> Session =>
    { user_id: uid, token: t }

// Token only used internally:
export fn Session @authorize(target_op str) -> bool =>
    verify_signature(@token, target_op)

9. See also

  • spec/decisions/02-types.mdD220 / D221 / D222 (semantics)
  • spec/decisions/07-modules.mdD47 (module-level pub vs per-field priv)
  • docs/plans/124-priv-field-visibility.md — umbrella plan
  • docs/dev/research/06-field-visibility-go-kubernetes.md — empirical default-visibility study (kubernetes 11099 structs / 35239 fields).
Last updated August 4, 2026