const and readonly

const answers when a value is known. readonly restricts runtime access and gives functions a contract against hidden side effects.

The distinction

Two separate concepts

const belongs to compile-time evaluation. readonly belongs to runtime mutability and access control.

A value can be known at compile time without representing a runtime variable. Conversely, a runtime value can be read-only even when it is produced by a function, file, network request or another runtime operation.

const: compile-time values

A const declaration must have an initializer that the compiler can evaluate. Constants may be declared at module level, associated with a struct, or locally inside a function.

const int MaxPlayers = 64;
const int BufferSize = MaxPlayers * 1024;

struct Physics
{
    const float Gravity = 9.81f;
}

Constant expressions may combine literals, other constants, arithmetic, comparisons, bitwise and boolean operations, primitive casts, sizeof, alignof and offsetof.

A local constant may use const var to infer its type from the initializer. The initializer must still be a constant expression, and the constant cannot be reassigned. Numeric literal defaults and suffixes determine the inferred type just as they do for other local declarations using var. Module-level and struct constants require an explicit type.

void Configure()
{
    const var maxPlayers = 64;                   // int
    const var bufferSize = 64u * 1024u;         // uint
    const var gravity = 9.81f;                   // float
    // const var count = ReadValue(); // error: runtime initializer
}
const nuint VectorSize = sizeof(Vector3);
const nuint YOffset = offsetof(Vector3, Y);

Layout queries and native integer casts remain compile-time expressions even when their value depends on the target ABI. They may be used in enum initializers and case labels; final validation occurs after target selection.

const T* and const T& are invalid. Use readonly for restricted pointer or reference access.

readonly: runtime immutability

A readonly value may be calculated at runtime. A readonly local binding requires an initializer and cannot be assigned again. Pointer declarations distinguish a readonly binding from readonly access to its target.

readonly int result = ReadValue();

// result exists at runtime, but cannot be reassigned.

readonly is also used for read-only references, read-only pointer access and functions whose external mutations must use explicitly mutable pointer/reference parameters. For an instance method, it also makes the receiver readonly.

readonly Vector3& position = GetPosition();

void Print(readonly byte* message);

public float readonly LengthSquared()
{
    return X * X + Y * Y + Z * Z;
}

A type-level readonly struct applies the same readonly-this capability model to every instance method/accessor and makes each instance field semantically readonly. Constructors remain able to initialize that state. See struct type modifiers.

Readonly access paths

For pointers and references, readonly restricts mutation through that particular access path. It does not necessarily make the underlying memory globally immutable.

Vector3 value = Vector3(1.0f, 2.0f, 3.0f);
Vector3& mutableView = value;
readonly Vector3& readonlyView = value;

mutableView.X = 10.0f; // allowed
// readonlyView.X = 10.0f;  not writable through this reference

In the parameter readonly byte* message, the pointed-to bytes cannot be modified through message. The pointer is a runtime value; the declaration is not a compile-time constant.

Not synchronization

readonly restricts this access path but does not make a load atomic and does not prevent another thread from writing through a different mutable path. A concurrent ordinary write still creates an undefined data race; see the memory model.

Pointer binding and pointed-to data

Place readonly before the pointed-to type to restrict writes through a pointer. Place it after * to prevent reassignment of the pointer itself. The distinction applies to locals, parameters and fields.

DeclarationReassign pointerWrite through pointer
T* pYesYes
readonly T* pYesNo
T* readonly pNoYes
readonly T* readonly pNoNo
int first = 10;
int second = 20;

readonly int* view = &first;
view = &second; // the pointer can change
// *view = 30;  error: readonly data

int* readonly fixedPointer = &first;
*fixedPointer = 30; // the data can change
// fixedPointer = &second;  error: readonly binding

readonly int* readonly fixedView = &first;

A mutable binding to readonly data, such as readonly int* view;, can be declared first and assigned later. A readonly pointer binding requires initialization at its local declaration.

Readonly access is preserved through dereference, indexing, member access and address-taking. It cannot be used to obtain a mutable reference or to call a mutating instance member.

Readonly functions, methods and return types

A readonly function or method places readonly immediately before its name, after the complete return type: int readonly GetValue() or void readonly Update(). This applies to free functions, static methods, interface methods and virtual or overriding methods. An instance method additionally has a readonly this receiver.

A readonly before the return type must restrict pointer or reference access, as in readonly int* or readonly int&. It never makes the method readonly. In particular, public readonly int GetValue() is a compile error; write public int readonly GetValue() instead.

No readonly binding on return values

Qualifiers such as readonly int, readonly SomeStruct, readonly int[] and readonly void have no meaning as return types and are rejected, even if the method also has its own readonly qualifier. The diagnostic is: 'readonly' cannot qualify a by-value return type; place 'readonly' before the method name to declare a readonly method.

Both readonly int& GetReference() and readonly int& readonly GetReference() remain valid: the first returns readonly reference access from a mutable method, while the second also makes the receiver readonly. Likewise, readonly int*[] remains meaningful as an array of pointers to readonly integers; it does not make the returned array binding readonly.

DeclarationReadonly function/methodReturned pointer
int* GetPointer(int* value)NoAccess to mutable int
readonly int* GetPointer(int* value)NoAccess to readonly int
int* readonly GetPointer(int* value)YesAccess to mutable int
readonly int* readonly GetPointer(int* value)YesAccess to readonly int

A returned value is not a named pointer binding: binding-level readonly does not exist for return values. In readonly int* readonly GetPointer(), the first qualifier belongs to the pointee and the second to the method. In int* readonly GetPointer(), the qualifier belongs only to the method; the returned pointer still allows writes to its target.

struct PointerSource
{
    public int* Pointer;

    public int readonly GetValue()
    {
        return *Pointer;
    }

    public int* readonly GetPointer(int* pointer)
    {
        return pointer;
    }

    public readonly int* readonly GetView()
    {
        return Pointer;
    }
}
int value = 10;
int other = 30;
PointerSource source = PointerSource { &value };
readonly PointerSource& reader = source;

int* pointer = reader.GetPointer(&value);
*pointer = 20; // allowed: the return type is int*

readonly int* view = reader.GetView();
// *view = 10;  error: readonly pointee
view = &other; // allowed: this local binding is mutable

Locals, parameters and fields still have all four pointer forms described above. Function readonly does not turn mutable parameters into readonly pointers or references. In this example GetPointer returns an explicitly supplied capability; GetView may expose the receiver's pointer only as readonly access.

No hidden side effects

Readonly functions have no hidden side effects; external mutation is only permitted through explicitly mutable pointer/reference capabilities present in the function signature. This is not mathematical purity: writing through an int* or int& parameter is observable, and reading static state is allowed.

Declare input access separately

The function/method qualifier does not make incoming pointers immutable. Use readonly T* input when the function must not modify data through an input pointer, or readonly T& for a reference. Use readonly T* readonly input to also forbid reassignment of the parameter's pointer binding. T* readonly input alone does not protect the pointed-to data. These parameter qualifiers are independent of the readonly function contract.

A readonly function or method cannot mutate hidden external state. It may freely mutate local state and state reachable through explicitly mutable pointer/reference parameters. Local structs, nested fields, arrays and allocations remain mutable; the function qualifier does not make them readonly values.

Mutable capabilities originating from parameters remain mutable through local variables, aliases, structures and arrays. Capabilities originating from the readonly method's this, globals or static state remain hidden even when copied into local containers. A contextually checked mutable member instead receives the actual object's origins. Readonly pointer/reference access remains protected regardless of its origin.

struct Data
{
    int* pointer;

    void readonly Change()
    {
        *pointer = 10; // error: pointer comes from this
    }
}

Make the permitted mutation explicit in the parameters instead:

void readonly Change(int* pointer)
{
    *pointer = 10; // allowed: explicit mutable parameter
}

Free and static function calls must target readonly declarations. Readonly method overloads are still preferred, and declared readonly members retain their existing contract. Non-readonly instance methods may be checked contextually on local or explicitly mutable receivers, as may constructors, destructors and property/indexer accessors. Extern functions need an explicit readonly contract.

Hidden mutable capabilities cannot be returned as writable access or stored through output parameters. Calls relying on a declared readonly contract also reject hidden capabilities passed to writable parameters. Arrays and structs supplied by value do not grant a readonly function mutation rights to their referenced storage; use explicit mutable pointer/reference parameters for that access. Contextually checked bodies retain the caller's actual argument origins instead of granting new permissions.

Flow-sensitive alias analysis

The compiler tracks struct fields and elements of small, statically sized arrays separately, following assignments in execution order. A guaranteed overwrite of one exact location replaces its old pointer origins. Alternative paths, loop back-edges and ambiguous indices combine possible origins. This analysis does not prove ownership or pointer lifetime.

Local structs and pointer origins

struct Inner
{
    public int Value;
    public int* Pointer;
}

struct Outer
{
    public Inner Inner;
}

Outer readonly Build(int* output)
{
    Outer result = Outer();
    result.Inner.Value = 42;
    result.Inner.Pointer = output;
    Outer& alias = result;
    *alias.Inner.Pointer = alias.Inner.Value;
    return result;
}

The local value, its nested fields and its reference alias can all be changed. The pointer still carries the permission supplied by output. Returning this struct by value is allowed. A pointer to a local variable is also writable while that variable is alive; copying it does not extend the variable's lifetime.

A local container does not turn hidden access into mutable permission:

struct State
{
    public static int* Pointer;
}

void readonly Invalid()
{
    Outer local = Outer();
    local.Inner.Pointer = State.Pointer;
    *local.Inner.Pointer = 10; // error: still hidden state
}

Independent field origins

Each struct field has its own storage location. A hidden pointer in data.Hidden does not affect data.Output or prevent changes to ordinary local fields. Nested paths such as data.Left.Pointer and data.Right.Pointer are distinct too. Access through a local pointer/reference alias uses the same field locations.

struct Shared
{
    public static int* Pointer;
}

struct Context
{
    public int* Hidden;
    public int* Output;
    public readonly int* Input;
    public int Value;
}

void readonly Process(int* output, readonly int* input)
{
    Context data = Context();
    data.Hidden = Shared.Pointer;
    data.Output = output;
    data.Input = input;
    data.Value = *data.Input;

    Context copy = data;
    *copy.Output = copy.Value; // allowed: explicit mutable capability
    // *copy.Hidden = 10;  error: hidden capability
    // *copy.Input = 10;   error: readonly pointee
}

Whole-struct assignment copies pointer/reference origins field by field, without merging siblings. Copies have independent field storage but retain aliases to the same pointed-to objects. Constructors, destructors and property/indexer accessors preserve this distinction when their bodies access fields.

The input field itself is declared readonly int*. Assigning a readonly int* input to an int* field is still a type error; field-sensitive provenance does not remove readonly access restrictions.

Passing only copy.Output to a mutable pointer parameter is allowed. Passing the whole mixed struct through a mutable pointer/reference to a declared readonly contract is conservatively rejected if that exposes hidden writable capabilities. A contextually checked instance method instead examines the fields it actually uses. Hidden receivers remain protected: a pointer read from a readonly method's this or a static struct does not become explicit merely because its field has a mutable pointer type.

Binding a local struct to T& does not change the origins of its pointer fields. The caller checks the referent's fields before granting the callee an explicit mutable reference:

struct Holder { public int* Pointer; }

void readonly WriteNested(Holder& value)
{
    *value.Pointer = 10;
}

void readonly Rejected()
{
    Holder local = Holder { Shared.Pointer };
    WriteNested(local); // error: local.Pointer still comes from hidden state
}

The same check follows nested fields, copies, pointer/reference aliases and a reference to a pointer binding such as int*&. None of these operations grants permission to modify hidden state.

Overwrites and control flow

An assignment to one exact storage location performs a strong update: the new pointer origins replace the old ones. This applies to local bindings, individual nested fields and aliases known to address one such location. A previous hidden value does not permanently mark that storage as hidden.

struct FlowState
{
    public static int* Pointer;
}

struct Data
{
    public int* Output;
}

void readonly Rewrite(int* output)
{
    int* ptr = FlowState.Pointer;
    int** alias = &ptr;
    *alias = output; // replaces ptr's hidden origin

    Data source = Data();
    source.Output = ptr;
    Data copy = Data();
    copy.Output = FlowState.Pointer;
    copy = source; // strong copy of each exact field
    *copy.Output = 10; // allowed
}

A struct copy preserves the field values at the copy point. Later reassignment of a pointer field in the source does not reassign that field in the copy. The pointed-to objects may still be shared. Overwriting one field never clears a hidden origin in a sibling field, and an illegal write before an overwrite remains an error.

Alternative branches are analyzed independently and their reachable continuation states are combined. Replacing a pointer on only one branch is not a guaranteed overwrite:

void readonly MaybeRewrite(bool choose, int* output)
{
    int* ptr = FlowState.Pointer;
    if (choose)
        ptr = output;
    *ptr = 10; // error: the other branch retains hidden access
}

If both branches assign explicit mutable capabilities, the later write is allowed. A branch that returns does not contribute state to statements after the branch. The same principle applies to switch bodies and short-circuit expressions: the right operand of && or || need not execute.

Loop checking accounts for both the entry state and paths returning to the next iteration, including continue. A loop that may run zero times cannot guarantee an overwrite. Inside an iteration, consecutive assignments still replace the state of exact locations. Paths leaving through break contribute to the state after the loop.

An assignment through an alias that may address several locations performs a weak update: each possible destination retains its previous origins as well as the new ones. An unknown array index updates all possible elements conservatively; it cannot erase the old origins of every element. Known indices can identify exact locations as described below. Opaque calls and summarized allocations remain conservative.

Contextual instance methods, property/indexer and lifecycle bodies use the same flow rules. Automatic stack-array destructors are checked with the state at scope exit, including exits through return, break and continue. These checks do not change the existing lifetime rules.

Separate array elements

For allocations whose elements can be distinguished statically, a constant index—including a rectangular index tuple—selects that element's own fields:

struct ArrayState { public static int* Pointer; }
struct Item { public int* Pointer; }

void readonly WriteSeparate(int index, int* output)
{
    Item[] values = new Item[2];
    values[0].Pointer = ArrayState.Pointer;
    values[1].Pointer = output;
    *values[1].Pointer = 10; // allowed: only element 1
    // *values[index].Pointer = 10;  error: index may select element 0
    delete(values);
}

Array-handle aliases refer to the same element locations. A write through an unknown index merges into every possible destination; a later exact overwrite can replace the origins of just one element. Pointer-offset aliases are widened conservatively, including pointers to fields inside elements. Cleanup checks each tracked element in reverse order.

Passing an element's address or reference to an opaque readonly contract also considers sibling elements reachable through pointer offsets. That call can be rejected if a sibling exposes hidden writable access, even though a direct operation on the selected element is safe. Contextual member bodies check the accesses they actually perform.

When the allocation shape or element origin cannot be distinguished statically, Xenon treats a possible element access conservatively. Repeated allocations in loops are also checked conservatively when aliases from earlier iterations may remain.

Mutable instance methods in readonly code

A readonly function or method may call a non-readonly instance method when its receiver is local or comes from an explicitly mutable T* or T& capability. The receiver must also permit mutation at the type level. The compiler analyzes the callee's body with the actual receiver and argument origins; a mutable receiver alone does not make the call safe.

struct Counter
{
    public int Value;

    public void Increment() { Add(1); }
    void Add(int amount) { Value += amount; }
}

void readonly Run(Counter& counter)
{
    counter.Increment(); // allowed: explicit mutable receiver
}

int readonly Local()
{
    Counter counter = Counter();
    counter.Increment(); // allowed: local receiver
    return counter.Value;
}

The nested Increment → Add calls preserve that same receiver. A pointer parameter works too: Counter* counter permits counter->Increment(). A static/global receiver, a receiver obtained from the enclosing readonly method's this, or a type-level readonly Counter*/readonly Counter& does not permit this mutable call. Even an empty non-readonly instance method is rejected on a hidden receiver.

Writes to static/global state remain errors anywhere in the call chain. Non-readonly free or static helpers remain forbidden, including helpers called inside an otherwise permitted instance method. Additional arguments retain their real origins and pointer/reference qualifiers; declaring a mutable parameter in the callee cannot turn a hidden argument into an explicit capability.

struct MethodState
{
    public static int* Pointer;
}

struct Writer
{
    public int* Hidden;
    public int* Output;

    public void Write()
    {
        *Output = 10;
        // *Hidden = 10;  error when called below
    }
}

void readonly Apply(int* output)
{
    Writer writer = Writer { MethodState.Pointer, output };
    writer.Write(); // the unused Hidden field does not restrict Output
}

Field and flow analysis continue inside the callee. On an exact local receiver, Output = MethodState.Pointer; Output = output; replaces the hidden origin before a later write through Output. A conditional overwrite, ambiguous alias or array element cannot guarantee the same replacement. Copying hidden writable access into an external output remains forbidden.

Virtual/interface dispatch uses a known receiver type when available; otherwise every possible compatible implementation must satisfy the readonly contract. A missing implementation or body is rejected. Recursive calls preserve the actual argument origins and effects through the complete call cycle.

A method such as Dispose follows these same rules:

struct Resource
{
    public int* Memory;

    public void Dispose()
    {
        free(Memory);
        Memory = null;
    }
}

void readonly Destroy(Resource& resource)
{
    resource.Dispose();
}

void readonly ReleaseLocal(int* memory)
{
    Resource resource = Resource { memory };
    resource.Dispose();
}

Here the memory must be a valid allocation to release (or null). Replacing it with a hidden pointer makes the contextual call invalid. Dispose is an ordinary method with no automatic invocation: this permission neither proves ownership nor changes destructor and lifetime rules.

Known and unknown dispatch targets

A directly constructed object has a known concrete type. An unrelated derived override does not make a call on that object unsafe:

struct DispatchState { public static int Value; }
struct Base
{
    public int Value;
    public virtual void Reset() { Value = 42; }
}
struct Evil : Base
{
    public override void Reset() { DispatchState.Value++; }
}

int readonly LocalDispatch()
{
    Base value = Base();
    value.Reset(); // checks Base.Reset, not Evil.Reset
    return value.Value;
}

Known types follow struct copies and pointer/reference aliases. Constructing Base() inside a loop also retains its known type; repeated storage alone does not introduce arbitrary derived types. Branches combine possible types, and an unknown alternative makes dispatch conservative again. Opaque calls that can replace an object invalidate its known type. A parameter of type Base& alone does not prove that its object is exactly Base:

void readonly UnknownDispatch(Base& value)
{
    value.Reset(); // error: Evil.Reset is a possible hidden effect
}

An interface conversion obtains its implementation map from the runtime object. Converting an Evil object through Base* therefore still selects Evil.Reset, and its hidden effect is rejected in readonly code. Known runtime types select the corresponding implementations. When only a static base-type bound is known, candidates are restricted to that hierarchy; unrelated interface implementers are not added. A completely unknown interface receiver still requires checking all compatible implementations.

If a genuinely possible override changes hidden state, rejecting the unknown call is required by the readonly contract. Xenon does not assume a safe concrete type without evidence. The actual backing object's field origins are preserved across interface conversions.

Recursive contextual methods

Recursive and mutually recursive methods preserve receiver and argument origins, memory effects and return origins through the complete call cycle. This keeps field distinctions through cycles such as A → B → C → A:

struct RecursionState { public static int* Pointer; }
struct Cycle
{
    public int* Hidden;
    public int* Output;
    public void A(int n)
    {
        if (n > 0) B(n - 1);
        *Output += 1;
    }
    void B(int n) { C(n); }
    void C(int n) { A(n); }
}

void readonly RecurseOutput(int* output)
{
    Cycle value = Cycle { RecursionState.Pointer, output };
    value.A(2); // allowed: the hidden field is never written through
}

By-value structs and array handles can participate in these checks without a blanket rejection. If a later recursive call receives hidden writable access and uses it, the propagated state exposes the forbidden effect. Recursive frames use conservative storage summaries, so this is not a proof of termination or of every guaranteed overwrite. If the analysis does not stabilize within its work limit, compilation fails instead of assuming the cycle is safe.

Constructors, destructors and accessors

A constructor may initialize a newly created object inside readonly code without declaring the constructor readonly. Destruction and deallocation of locally created state are also permitted, as is free through an explicitly mutable pointer parameter. Freeing a hidden pointer is forbidden, including through a local alias. See readonly cleanup for the supported lifetime rules.

The compiler checks lifecycle bodies using the actual object and argument origins. Their receiver is the local or explicitly supplied object, rather than the hidden receiver of the surrounding readonly method. Pointer fields retain their origins: a constructor may copy a hidden pointer for reading, but cannot turn it into permission to write or free its target. Field initializers and inherited lifecycle calls are checked too.

Property and indexer getters/setters without a readonly modifier are likewise allowed on mutable local or explicitly supplied objects when their bodies have no forbidden hidden effects. Updating local fields is allowed; writing a static field from a setter is not. Their virtual/interface dispatch follows the same known-target and conservative fallback rules as ordinary instance methods.

These bodies may also call non-readonly instance methods on permitted receivers, with the same contextual checks throughout the call chain. A readonly pointer/reference still cannot be used to mutate its target. Non-readonly free/static helpers remain forbidden; a constructor, destructor or setter does not bypass that restriction.

Choosing the keyword

Use const when the compiler must know and evaluate the value. Use readonly when the value or access path exists at runtime but mutation must be restricted.

const int HeaderSize = 16;        // compile time
readonly int bytesRead = Read(); // runtime