RFC 052: Module Static Storage¶
- Status: Implemented
- Created: 2026-04-07
- Author(s): Danny Meijer (@dannymeijer)
- Related:
- RFC 008 (const bindings)
- RFC 017 (validated newtypes with implicit coercion)
- RFC 023 (compilable stdlib and Rust module binding)
- RFC 033 (ctx keyword)
- Issue: https://github.com/dannys-code-corner/incan/issues/242
- RFC PR: https://github.com/dannys-code-corner/incan/pull/243
- Written against: v0.2
- Shipped in: v0.2
Summary¶
This RFC proposes static as a new module-level declaration form for process-lifetime mutable storage owned by a module. const remains the immutable top-level binding form. static is intentionally Python-like: it exposes one live module-scoped binding initialized once at module load time, supports ordinary mutable values, and may be exported when shared mutable module state is part of the intended API.
Core model¶
constremains the immutable top-level declaration form and does not change in this RFC.staticdeclares one mutable storage cell owned by the defining module and initialized once, eagerly, when the module is initialized.- Reads of a
staticexpose the live stored value rather than a cloned copy; assignment to the static name mutates the storage cell rather than rebinding a local variable. staticis for module-owned runtime state, not configuration singletons and not compile-time constants.- This RFC introduces module static storage, not a full synchronization, atomics, or read-only view surface.
Motivation¶
Incan can already express immutable top-level values via const, ordinary local mutable variables, and stateful instance fields on class values. It cannot currently express one narrow but important category of state: library-owned mutable storage that persists across calls without being threaded through every API explicitly.
That gap is small in syntax but real in effect. A library can easily need a monotonic id allocator, a module-local registry, a cache, or a one-time scratch store. Today the author must either thread an explicit state object through the public API or drop into Rust interop for something that is conceptually simpler than the surrounding Incan code.
That fallback is the wrong showcase boundary for the language. RFC 023 explicitly frames @rust.extern as a narrow leaf mechanism and says it should be applied at the "smallest possible set of primitives" where the host boundary is genuinely irreducible. A module-local counter or registry is not conceptually irreducible runtime I/O; it is a missing language/runtime storage capability.
RFC 033 does not solve this problem. ctx is a typed configuration surface that produces a "set-once singleton" with environment-aware initialization. That is the right tool for application configuration and the wrong tool for mutable library runtime state.
Goals¶
- Introduce
staticas a first-class module-level declaration for mutable persistent storage. - Keep the mental model simple:
constis immutable,staticis mutable module-owned state. - Support ordinary library use cases such as counters, registries, and caches without Rust fallback.
- Preserve clear ownership of the module binding while still allowing intentional shared mutable exports through
pub static. - Leave room for future synchronization primitives without making this RFC depend on them.
Non-Goals¶
- Replacing
constor redefining its semantics. - Reusing
ctxfor general mutable runtime state. - Introducing a full atomics, locks, or thread-local storage surface in the same RFC.
- Introducing class-owned static storage or class variables. That carries separate inheritance, member lookup, and shadowing semantics and should be handled by a dedicated follow-up RFC if Incan wants it.
- Turning Incan into a language that encourages arbitrary ambient global mutation as the default design style.
- Defining distributed, cross-process, or persisted storage semantics.
Guide-level explanation¶
A module-owned counter¶
type StoreId = newtype int
static next_store_id: StoreId = StoreId(0)
def allocate_store_id() -> StoreId:
current = next_store_id
next_store_id = StoreId(current.0 + 1)
return current
next_store_id is not a local variable and not a const. It is one storage cell owned by the module. Each call to allocate_store_id() sees the updated value from prior calls.
This example uses explicit newtype construction and unwrapping so the RFC does not silently depend on implicit coercion or numeric operator lifting for newtypes. If RFC 017 lands with typed-initializer coercion, static next_store_id: StoreId = 0 may also become valid, but that is not required by this RFC.
A module-local registry¶
static registered_names: list[str] = []
def register_name(name: str) -> None:
registered_names.append(name)
def registered_count() -> int:
return len(registered_names)
The registry persists across function calls without forcing callers to carry a registry object through every API boundary.
Exported shared mutable state¶
pub static registered_names: list[str] = []
def register_name(name: str) -> None:
registered_names.append(name)
Another module may import registered_names and observe or mutate the same live list value. The binding itself still belongs to the defining module, so rebinding that name from another module is not allowed.
How users should think about it¶
- Use
constwhen the value is immutable. - Use
staticwhen the module owns mutable runtime state and, when exported, intentionally shares that state. - Use
classfields when a caller should own and pass around the state explicitly. - Use
ctxwhen the problem is typed application configuration, not mutable runtime storage. - Name
staticbindings like ordinary mutable variables (snake_case), not like constants. They are shared storage cells, not immutable values.
Reference-level explanation¶
Declaration form¶
A static declaration has the form:
static name: Type = initializer
An exported static has the form:
pub static name: Type = initializer
Placement rules¶
staticdeclarations must appear in module scope, as part of the module body. They do not need to be the first declaration in the file.staticdeclarations must not appear inside functions, methods, traits,modelbodies,classbodies, or control-flow blocks.- A
staticdeclaration must include an explicit type annotation. - A
staticdeclaration must include an initializer.
Storage and lifetime¶
- Each
staticdeclaration must create exactly one storage cell per module instance. - The initializer must run exactly once, eagerly, when the module is initialized.
- The value stored in the cell must persist for the lifetime of that module instance.
- Reads of the static must observe the current contents of the storage cell, not a copied constant value.
- Binding a name to a static's value must expose the live stored value rather than a cloned copy.
Mutation rules¶
- Assignment to a
staticname in its defining module must mutate the storage cell. - Compound assignment to a
staticname in its defining module must mutate the storage cell. - Method calls that mutate the current value stored in a
staticare permitted in the defining module when the stored type supports that operation. - Direct assignment or compound assignment to an imported
staticname from a different module must be a compile error, even if the static is public. - If a
pub staticexposes a mutable value, mutating that live value through ordinary aliasing, method calls, or field assignment is permitted. - Rebinding a local variable with the same name must follow ordinary shadowing rules and must not mutate the static unless the name being assigned resolves to the static itself.
Visibility and imports¶
staticdeclarations follow ordinary module visibility rules.- A non-
pubstatic must remain private to its defining module. - A
pub staticmay be imported or referenced from another module. - Importing a
pub staticexposes the same live stored value rather than a copied snapshot. - Public visibility does not grant ownership of the binding cell itself; imported rebinding remains invalid.
Initialization restrictions¶
- A
staticinitializer is evaluated at module initialization time, not under RFC 008 const-evaluable rules. - A
staticinitializer may use ordinary expression forms that are valid at module scope, including constructor calls and function calls, subject to the restrictions below. - A
staticinitializer must not assign to anystatic. - A
staticinitializer may reference earlier-declaredstaticvalues and visibleconstvalues. - A
staticinitializer must not reference a later-declaredstatic. - Cyclic initialization between statics must be rejected.
Type checking¶
- The initializer expression must be assignable to the declared static type.
- Later assignments to the static must be assignable to the declared static type.
- Reads of a static use the declared type directly.
- A
staticdeclared with a mutable value type uses that type's ordinary mutation surface; this RFC does not impose implicit copy or clone semantics on static values. constandstaticremain distinct declaration kinds; aconstmust not be reassigned, while astaticmay be mutated.
Diagnostics¶
The compiler should produce targeted diagnostics for at least these cases:
staticused outside module top level.- Missing type annotation on a
static. - Missing initializer on a
static. - Attempted assignment to an imported
static. - Attempted reassignment of a
constwhere the author likely meantstatic. - Forward reference to a later
staticduring initialization. - Cyclic static initialization.
Design details¶
Syntax¶
This RFC introduces static as a distinct top-level declaration form. The concrete declaration syntax is defined above in Reference-level explanation / Declaration form.
This RFC does not introduce static mut. static is the mutable storage form. const already owns the immutable top-level role.
Semantics¶
static introduces module-owned mutable runtime storage. It is not a local rebinding and not a compile-time constant. Reads access the current stored value directly. Writes replace or mutate that stored value according to the operation performed. When a static stores a mutable object, ordinary aliasing exposes the same live object rather than a copied value.
The distinction from const is intentional and sharp:
constis an immutable named value.staticis a mutable storage cell with module lifetime.
This RFC intentionally distinguishes binding ownership from object mutability. The defining module owns the binding cell, so imported rebinding is invalid. But if a public static stores a mutable object, that shared object may be mutated through ordinary aliasing and method calls.
Rust's static is the closest familiar analogue, but Incan is not copying Rust literally. The Rust Reference says a static item "represents an allocation in the program" and that all references point at the same allocation. It also says the initializer is a constant expression and that mutable statics require unsafe. Incan keeps the single-live-cell intuition while intentionally choosing eager module initialization and one mutable static form instead of Rust's static / static mut split. See The Rust Reference: Static items.
Interaction with existing features¶
const:constremains the immutable top-level binding form. This RFC does not relaxconstreassignment rules.ctx:ctxremains the configuration singleton surface.staticis for mutable runtime storage, not configuration resolution.class:classfields remain the right tool when state should be owned by an explicit value passed through the API.- Class-owned state: this RFC does not introduce class-level statics or singleton type members. Those semantics should be designed separately if Incan wants them.
- Imports / modules: statics become ordinary module members for name resolution and visibility;
pub staticintentionally allows shared mutable exports while keeping rebinding rules module-owned. - Async / concurrency: this RFC guarantees one-time eager initialization and a coherent shared-state surface, but it does not promise that concurrent compound mutations on escaped mutable aliases are serialized or atomic.
- Rust interop:
staticshould reduce the need for Rust-backed helper leaves whose only job is to hold trivial module-local state.
Compatibility / migration¶
This RFC is additive. Existing programs remain valid. The only new reserved word introduced is static, so any existing user-defined identifier named static would need to migrate once the keyword lands.
Libraries that currently use Rust-backed helpers purely to allocate ids or hold trivial module state may migrate those leaves into pure Incan once static exists, but this RFC does not require such migration.
Alternatives considered¶
- Keep using Rust-backed helpers for counters and registries: rejected because it hides a genuine language/runtime gap behind interop and weakens Incan as the source-of-truth surface for its own libraries.
- Use
ctxfor library-owned mutable state: rejected because RFC 033's model is a set-once configuration singleton, not a general mutable runtime storage facility. - Introduce only a stdlib counter or registry type: rejected because it solves specific symptoms rather than the language capability gap; library-owned persistent storage should not require a bespoke standard type for every pattern.
- Allow bare top-level variables without a keyword: rejected because
constalready owns immutable top-level values and a distinct keyword makes stateful storage easier to reason about. - Allow
staticinsideclassbodies in the same RFC: deferred. Class-owned shared storage is conceptually adjacent, but it requires separate decisions around inheritance, shadowing, lookup through the type surface, and whether instances may read class statics through attribute access. - Split the feature into
staticandstatic mut: rejected for now becauseconstalready covers the immutable top-level role and a single mutablestaticform keeps the mental model simpler. - Clone-on-read static values: rejected because it pulls the feature away from Python-style module-state expectations and would make exported mutable statics feel unlike ordinary live shared objects.
Drawbacks¶
staticintroduces ambient mutable state, which can be misused and can make code harder to reason about if over-applied.- Module initialization becomes more semantically important because statics now have runtime initialization behavior.
- Shared mutable exports mean aliasing becomes a deliberate part of the language surface rather than a purely internal runtime detail.
- Concurrency semantics become a language/runtime concern rather than something authors can ignore, and this RFC does not make shared mutable statics automatically race-free.
- Tooling must make the distinction between immutable declarations and mutable module state visible and understandable.
Implementation architecture¶
One recommended internal model is to treat each static as one compiler-recognized module storage cell with explicit read and write operations in lowering, rather than lowering it as a disguised local variable. Backends should preserve the "one cell per module instance" contract, eager top-to-bottom initialization behavior, and live shared-object semantics rather than relying on constant inlining or clone-on-read shims.
This RFC does not require one specific backend storage strategy. It only requires the user-visible semantics described above.
Layers affected¶
- Parser / AST: the parser must recognize
staticas a top-level declaration form and represent it distinctly fromconst. - Typechecker / Symbol resolution: the compiler must typecheck static initializers and assignments, resolve statics as module members, reject imported rebinding, and enforce earlier-only static references during initialization.
- IR lowering: lowering must preserve the difference between reading a module storage cell and reading an immutable value, including live shared-object semantics for mutable stored values.
- Emission: generated output must preserve one-cell-per-module-instance semantics and eager top-to-bottom initialization behavior.
- Stdlib / Runtime (
incan_stdlib): runtime support may be needed for initialization ordering and shared mutable storage behavior, but this RFC does not require automatic synchronization for escaped aliases. - Formatter: the formatter must support
staticdeclarations and preserve canonical spacing and ordering. - LSP / Tooling: hover, completion, rename, diagnostics, and symbol displays should surface
staticas mutable module state rather than as a constant.
Implementation Plan¶
- Add
staticas a distinct declaration kind in the lexer/parser/AST/formatter and surface it through LSP/tooling. - Extend typechecking and symbol resolution so module statics are first-class bindings with declaration-order validation, imported-rebinding diagnostics, and local live-alias tracking.
- Introduce first-class IR support for module statics plus runtime storage helpers in
incan_stdlibso reads, writes, and direct aliases preserve live storage semantics. - Extend library export manifests and
pub::import resolution sopub staticshares the same storage cell across modules. - Cover the feature with parser, typechecker, codegen snapshot, and end-to-end runtime tests, then update language docs and release notes.
Implementation log¶
- Parser/AST/formatter support for
static/pub static - Typechecker support for module-scope placement, required annotation/initializer, declaration-order rules, cycle rejection, and imported-static rebinding diagnostics
- IR lowering and Rust emission support for first-class module static storage
- Runtime storage helpers in
incan_stdlibfor live reads, writes, and direct aliases - Library manifest /
pub::support for exported statics - Language docs and release notes updated
- Targeted parser, typechecker, codegen snapshot, and runtime tests added
- Full repository verification gate (
mkdocs build --strict,make fmt,make pre-commit-full,make smoke-test)
Design Decisions¶
pub staticis part of v1. Exporting shared mutable module state is an intended use case rather than a deferred capability.- Static initialization is eager and follows module declaration order.
- Static initializers may reference earlier-declared statics and visible consts, but not later-declared statics.
staticuses live shared-object semantics rather than clone-on-read semantics.- Imported rebinding of a
staticname is rejected, but mutation through ordinary aliases and method calls on exported mutable values is allowed. - This RFC does not promise automatic race-free or atomic behavior for concurrent mutations on escaped mutable aliases.