Proposal: Index and Field Assignment via Copy-on-Write Desugaring
Status: Approved Author: Eric (with AI assistance) Created: 2026-02-17 Approved: 2026-02-17 Affects: Grammar, type system, type checker, evaluator, standard library
Executive Summary
This proposal introduces index and field assignment syntax as syntactic sugar for copy-on-write reassignment. The expression list[i] = x desugars to list = list.updated(key: i, value: x), where updated returns a new collection with the specified element replaced. Field assignment (state.name = x) desugars to struct spread reconstruction (state = { ...state, name: x }). Under ARC with refcount == 1 (the common case for local mutable bindings), the “copy” is optimized to in-place mutation automatically.
Key features:
IndexSettrait with@updated (self, key: Key, value: Value) -> Selfmethod- Grammar extension to allow index and field expressions on the left side of assignment
- Nested index assignment (
list[i][j] = x) via recursive desugaring - Field assignment (
state.name = x) via struct spread reconstruction - Mixed chains (
state.items[i] = x,list[i].name = x) combining both forms - Compound assignment (
list[i] += 1,state.count += 1) via two-step desugaring - Built-in implementations for
[T]and{K: V}
Problem Statement
No Way to Update a Collection Element
There is currently no way to replace an element in a list at a given index. The language provides:
- Read access:
list[i]desugars tolist.index(key: i)via theIndextrait - No write access: No
set(),updated(), orreplace()method exists on any built-in type - No index assignment: The grammar restricts assignment to bare identifiers:
assignment = identifier "=" expression
The only workaround is manual slice-and-concatenate:
// To set list[2] = 99 in a list of length 5:
let list = list.take(count: 2) + [99] + list.skip(count: 3)
This is O(n), error-prone (off-by-one on indices), and unacceptable ergonomically.
Maps Are Equally Impaired
There is no way to insert or update a key-value pair in a map. The only option is spread syntax to build a new map from scratch:
let map = {..."old_map", "new_key": value}
This works for insertion but cannot express “replace the value at an existing key” without knowing all keys.
No Way to Update a Struct Field
Updating a single field of a struct requires spread syntax with the full type name:
let state = GameState { ...state, score: state.score + 1 }
This is verbose, requires knowing the type name, and scales poorly with nesting. The natural syntax state.score = state.score + 1 is not supported.
Previous Rejection Was Based on Stale Reasoning
Both the Index Trait Proposal and the Custom Subscripting Proposal rejected index assignment. Their reasoning:
“Ori’s memory model prefers immutable updates. Index assignment would require mutable references, which Ori avoids.”
This conflates in-place mutation (modifying memory through a reference) with reassignment (rebinding an identifier to a new value). Per spec/05-variables.md, Ori supports mutable bindings:
let x = 0
x = x + 1 // Valid: reassignment, not mutation
Index and field assignment can be expressed as reassignment without mutable references:
list[i] = x
// Desugars to:
list = list.updated(key: i, value: x)
state.name = x
// Desugars to:
state = { ...state, name: x }
Errata have been appended to both prior proposals documenting this correction.
Proposed Design
The IndexSet Trait
/// Trait for types that support producing a copy with an element replaced.
///
/// `updated` returns a new value identical to `self` except at the given key.
/// Combined with ARC copy-on-write, this enables efficient index assignment.
trait IndexSet<Key, Value> {
@updated (self, key: Key, value: Value) -> Self
}
The trait is added to the prelude alongside Index. The updated method is publicly callable — users who prefer functional style can call list.updated(key: 0, value: x) directly without using the assignment sugar.
Design rationale: The method is named updated (not set or replace) because it returns a new value rather than modifying in place. This matches Ori’s value-semantics vocabulary and avoids implying mutation. The name follows Swift’s convention where update is the mutating form and updated is the non-mutating form.
Desugaring: Index Assignment (Simple Case)
list[i] = x
Desugars to:
list = list.updated(key: i, value: x)
This is pure syntactic sugar. The compiler transforms the assignment target from an index expression to a call to updated, then reassigns the binding. The binding must be mutable (non-$).
Desugaring: Field Assignment
state.name = x
Desugars to struct spread reconstruction:
state = { ...state, name: x }
The compiler identifies the root binding (state), determines its struct type via type inference, and generates a spread expression that copies all fields except the one being assigned. The binding must be mutable (non-$).
Field assignment does not require the IndexSet trait — it uses the existing struct spread mechanism. No new trait or method is needed for pure field updates.
Desugaring: Nested Index Case
list[i][j] = x
Desugars inside-out. The innermost index assignment becomes an updated call, and each outer level wraps it:
list = list.updated(key: i, value: list[i].updated(key: j, value: x))
Three levels deep:
grid[x][y][z] = val
// Desugars to:
grid = grid.updated(
key: x,
value: grid[x].updated(
key: y,
value: grid[x][y].updated(key: z, value: val),
),
)
The pattern generalizes: for target[k1][k2]...[kN] = val, the compiler builds N nested updated calls, reading intermediate values via index for each level.
Desugaring: Mixed Chains (Field + Index, Index + Field)
Assignment targets can freely mix field access and indexing in any order. The compiler processes the chain from the assignment point back to the root binding, wrapping each step appropriately:
- Field step → struct spread reconstruction
- Index step →
updated()call
Field then index (state.items[i] = x):
state.items[i] = x
// Desugars to:
state = { ...state, items: state.items.updated(key: i, value: x) }
Index then field (list[i].name = x):
list[i].name = x
// Desugars to:
list = list.updated(key: i, value: { ...list[i], name: x })
Deep mixed chain (game.levels[i].enemies[j].hp = 0):
game.levels[i].enemies[j].hp = 0
// Desugars to:
game = { ...game,
levels: game.levels.updated(key: i, value: { ...game.levels[i],
enemies: game.levels[i].enemies.updated(key: j, value: { ...game.levels[i].enemies[j],
hp: 0,
}),
}),
}
The algorithm is mechanical: walk the chain from right (assignment point) to left (root binding), wrapping each field step in { ...receiver, field: inner } and each index step in receiver.updated(key: k, value: inner).
Desugaring: Compound Assignment
Compound assignment operators (+=, -=, *=, etc.) compose with index and field assignment via two-step desugaring:
list[i] += 1
// Step 1: desugar compound assignment
list[i] = list[i] + 1
// Step 2: desugar index assignment
list = list.updated(key: i, value: list[i] + 1)
This works for all compound operators and all assignment target forms:
state.count += 1
// Step 1: state.count = state.count + 1
// Step 2: state = { ...state, count: state.count + 1 }
matrix[i][j] *= 2.0
// Step 1: matrix[i][j] = matrix[i][j] * 2.0
// Step 2: matrix = matrix.updated(key: i, value: matrix[i].updated(key: j, value: matrix[i][j] * 2.0))
No new traits or mechanisms are needed — compound assignment is purely a pre-existing desugaring that now applies to the expanded set of assignment targets.
Grammar Changes
The current grammar production for assignment is:
assignment = identifier "=" expression .
This proposal extends it to:
assignment = assignment_target "=" expression .
assignment_target = identifier { index_suffix | field_suffix } .
index_suffix = "[" expression "]" .
field_suffix = "." identifier .
The left-hand side of an assignment (including compound assignment) can now be an identifier followed by any number of index and field access operations. The root must still be a bare identifier (a mutable binding in scope).
What is NOT allowed: Arbitrary expressions on the left side. Only identifier-rooted chains of field access and indexing are valid. Function calls, method calls, and other expressions cannot appear as assignment targets.
Type Checking Rules
-
Root binding must be mutable: The identifier at the root of the assignment target must refer to a mutable binding (not
$-prefixed, not a parameter, not a loop variable). -
Field access validation: Each
.fieldin the chain must be a valid field of the receiver’s struct type. The receiver type must be a struct (not an enum, trait object, or primitive). -
Index trait required for reads: Each
[key]in the chain (except the last) must resolve via theIndex<Key, Value>trait on the receiver type. -
IndexSet trait required for index writes: The final
[key]in the chain (if present) must resolve viaIndexSet<Key, Value>on the receiver type. The value type of theIndeximpl and the value type of theIndexSetimpl must agree. -
Type of assigned value: The right-hand side expression must be assignable to the type at the assignment point — the
Valuetype parameter of theIndexSetimpl for index targets, or the field type for field targets. -
Self type returned:
IndexSet.updatedreturnsSelf, so the reassignment is always type-correct. Struct spread expressions preserve the struct type.
Implementation Note: Type-Directed Desugaring
The desugaring described above is not a parser-level transformation. It is type-directed and must occur during or after type inference:
- Field assignment requires knowing the struct type to generate correct spread syntax.
- Index assignment requires resolving
IndexSettrait implementations. - Mixed chains require type information at every step to determine whether to use spread or
updated.
The parser’s role is limited to accepting the extended assignment_target grammar and emitting an AST node that captures the chain of field/index accesses. The actual desugaring into updated calls and spread expressions is performed by the type checker or a type-directed lowering pass.
Constraint: Index and IndexSet Consistency
For a type T implementing both Index<K, V> and IndexSet<K, V>, the following must hold:
For all t: T, k: K, v: V:
t.updated(key: k, value: v).index(key: k) == v
This is a semantic contract (not enforced by the compiler) stating that reading back a just-written key returns the written value.
ARC Optimization
Ori uses ARC (Automatic Reference Counting) with copy-on-write semantics, as described in spec/15-memory-model.md.
When list.updated(key: i, value: x) is called and list has a refcount of 1 (i.e., no other binding shares the same backing storage), the runtime can perform the update in place rather than copying. This is an existing ARC optimization, not something new introduced by this proposal.
The common case for local mutable bindings is refcount == 1:
let list = [1, 2, 3] // refcount = 1
list[0] = 10 // desugars to list = list.updated(key: 0, value: 10)
// refcount was 1, so updated() mutates in-place
// result: list = [10, 2, 3], no allocation
When refcount > 1 (the value is shared), updated allocates a new collection with the modification applied, and the binding is rebound to the new value. The old value’s refcount decreases by 1:
let list = [1, 2, 3] // refcount = 1
let alias = list // refcount = 2
list[0] = 10 // refcount > 1, so updated() copies
// list -> [10, 2, 3] (new allocation, refcount = 1)
// alias -> [1, 2, 3] (old allocation, refcount = 1)
This is identical to Swift’s copy-on-write behavior for Array, Dictionary, and other value types under ARC.
Standard Implementations
List: [T]
impl<T> [T]: IndexSet<int, T> {
@updated (self, key: int, value: T) -> [T] =
if key < 0 || key >= self.len() then
panic(msg: "index out of bounds: " + key.to_str())
else
// intrinsic: compiler-provided
// When refcount == 1, modifies in place
}
Behavior: Returns a new list identical to self except at position key. Panics if key is out of bounds (negative or >= length). Matches the panic behavior of Index<int, T> for [T].
Map: {K: V}
impl<K: Eq + Hashable, V> {K: V}: IndexSet<K, V> {
@updated (self, key: K, value: V) -> {K: V} =
// intrinsic: compiler-provided
// Inserts or replaces the entry for key
// When refcount == 1, modifies in place
}
Behavior: Returns a new map identical to self except the entry for key is set to value. If key already exists, its value is replaced. If key does not exist, a new entry is inserted. This never panics.
Note: Map’s Index returns Option<V>, but IndexSet takes a bare V. This asymmetry is intentional — reading may find nothing, but writing always provides a value.
String: NOT Supported
// str does NOT implement IndexSet.
// Strings are immutable sequences of bytes/codepoints.
"hello"[0] = "H" // ERROR: `str` does not implement `IndexSet<int, str>`
Rationale: String indexing by integer returns a single codepoint as str, but replacing a codepoint may change the byte length of the string, making index-based replacement semantically confusing and potentially O(n). String manipulation should use dedicated methods (replace, splice, etc.) that make the cost explicit.
Fixed-Capacity List: [T, max N]
impl<T, $N: int> [T, max N]: IndexSet<int, T> {
@updated (self, key: int, value: T) -> [T, max N] =
if key < 0 || key >= self.len() then
panic(msg: "index out of bounds: " + key.to_str())
else
// intrinsic: compiler-provided
}
Behavior: Same as [T] but preserves the fixed-capacity type.
Error Cases
Immutable Binding
error[E____]: cannot assign to immutable binding
--> src/main.ori:3:5
|
2 | let $list = [1, 2, 3]
| ----- binding declared as immutable
3 | $list[0] = 10
| ^^^^^^^^^^^^^ cannot assign through immutable binding
|
= help: remove the `$` prefix to make the binding mutable
Parameter Assignment
error[E____]: cannot assign to parameter
--> src/main.ori:2:5
|
1 | @update (items: [int]) -> [int] = {
| ----- non-self parameters are always immutable
2 | items[0] = 99
| ^^^^^^^^^^^^^^ cannot assign to parameter
|
= help: bind to a local variable first: `let items = items`
Loop Variable Assignment
error[E____]: cannot assign to loop variable
--> src/main.ori:2:9
|
1 | for item in items do
| ---- loop variable
2 | item[0] = 99
| ^^^^^^^^^^^^ cannot assign to loop variable
Type Not Indexable for Write
error[E____]: type `str` does not support index assignment
--> src/main.ori:3:5
|
3 | text[0] = "H"
| ^^^^^^^^^^^^^^ `IndexSet<int, str>` is not implemented for `str`
|
= note: strings do not support element replacement
= help: use string methods like `replace` instead
Invalid Field for Assignment
error[E____]: no field `missing` on type `GameState`
--> src/main.ori:3:5
|
3 | state.missing = 42
| ^^^^^^^ unknown field
|
= note: `GameState` has fields: score, level, lives
Type Mismatch on Value
error[E____]: mismatched types in index assignment
--> src/main.ori:3:16
|
3 | list[0] = "hello"
| ^^^^^^^ expected `int`, found `str`
|
= note: `[int]` implements `IndexSet<int, int>`
Key Type Mismatch
error[E____]: mismatched types in index expression
--> src/main.ori:3:10
|
3 | list["key"] = 10
| ^^^^^ expected `int`, found `str`
|
= note: `[int]` implements `IndexSet<int, int>`, not `IndexSet<str, _>`
Comparison with Other Languages
Swift (Closest Model)
Swift’s approach is the closest prior art. Swift arrays and dictionaries are value types with copy-on-write under ARC:
var array = [1, 2, 3]
array[0] = 10 // In-place if uniquely referenced, copies otherwise
Swift uses a subscript declaration with get and set accessors. The set accessor receives newValue implicitly and mutates self (which is inout in a mutating context).
Ori’s difference: Ori does not have inout, mutating, or mutable method receivers. Instead, updated returns a new value and the compiler generates a reassignment. The ARC optimization produces the same runtime behavior as Swift, but the semantic model is purely functional (no mutation vocabulary).
Rust (IndexMut with &mut)
Rust uses IndexMut which requires a mutable borrow:
let mut v = vec![1, 2, 3];
v[0] = 10; // Calls IndexMut::index_mut(&mut v, 0) = 10
This is true in-place mutation through a mutable reference. Rust’s borrow checker ensures no aliasing.
Ori’s difference: Ori has no mutable references or borrow checker. The copy-on-write approach achieves the same ergonomics without the conceptual overhead of borrowing. The tradeoff is that shared values incur a copy on write, whereas Rust would reject the program at compile time if aliasing is detected.
Python (__setitem__)
Python uses the __setitem__ dunder method:
lst = [1, 2, 3]
lst[0] = 10 # Calls lst.__setitem__(0, 10)
This is true in-place mutation with no copy. Python objects are always heap-allocated and reference-counted, so there is no concept of value semantics or copy-on-write.
Ori’s difference: Ori’s desugaring to updated + reassignment preserves value semantics. Two bindings pointing to the same list remain independent after one is modified via index assignment.
Kotlin (Operator Overloading)
Kotlin desugars index assignment similarly to this proposal:
list[i] = x
// Desugars to:
list.set(i, x)
However, Kotlin’s set mutates in place (Kotlin collections are reference types).
Ori’s difference: Ori’s updated returns a new value; the reassignment is explicit in the desugared form.
| Language | Mechanism | Semantics | Copy-on-Write | Value Semantics |
|---|---|---|---|---|
| Ori | IndexSet trait + desugaring | Reassignment | Yes (ARC) | Yes |
| Swift | subscript { set } | In-place via inout | Yes (ARC) | Yes |
| Rust | IndexMut trait | &mut borrow | No (borrow checker) | No (references) |
| Python | __setitem__ | In-place mutation | No | No |
| Kotlin | operator set | In-place mutation | No | No |
Grammar Changes Required
Current Grammar (grammar.ebnf)
assignment = identifier "=" expression .
binding = let_expr | assignment .
Proposed Grammar
assignment = assignment_target "=" expression
| assignment_target compound_op expression .
assignment_target = identifier { "[" expression "]" | "." identifier } .
compound_op = "+=" | "-=" | "*=" | "/=" | "%=" .
binding = let_expr | assignment .
The assignment_target production allows an identifier optionally followed by any combination of index access ([expr]) and field access (.ident). This is intentionally more restrictive than a general postfix_expr — only identifiers, indexing, and field access are valid. Method calls, function calls, ?, as, and other postfix operations are not valid assignment targets.
Compound assignment operators apply to all assignment target forms uniformly.
Spec Changes Required
grammar.ebnf
Update the assignment and add the assignment_target production as shown above.
05-variables.md
Add a section on extended assignment describing:
- Index assignment syntax and what it desugars to
- Field assignment syntax and what it desugars to
- Compound assignment with extended targets
- Requirement that the root binding be mutable
- Nested and mixed chain examples
- Note that desugaring is type-directed
09-expressions.md
Update the Index Trait section to cross-reference IndexSet and index assignment. Add examples of index and field assignment in the expressions chapter.
07-properties-of-types.md
Add IndexSet trait definition alongside the existing Index trait documentation. Document the semantic contract between Index and IndexSet.
15-memory-model.md
Add a note about how ARC copy-on-write applies to index assignment, demonstrating that the common case (refcount == 1) results in in-place modification.
Implementation Plan
Phase 1: IndexSet Trait and updated Method
- Define
IndexSettrait in prelude - Register
updatedas a built-in method on[T],{K: V}, and[T, max N]in the evaluator - Implement
updatedwith ARC-aware copy-on-write inori_patterns/ori_eval
Phase 2: Parser Changes
- Extend the parser to accept
assignment_target(identifier + index/field chains) on the left side of=and compound assignment operators - Emit a new AST node (or annotated assignment node) that captures the chain of index/field accesses
Phase 3: Type-Directed Desugaring
- In the type checker or a type-directed lowering pass, desugar assignment targets:
[key]steps →updated()calls (requiresIndexSettrait resolution).fieldsteps → struct spread reconstruction (requires struct type information)
- Handle nested cases, mixed field-index chains, and compound assignment
- This phase cannot occur in the parser — it requires type information
Phase 4: Type Checker Integration
- Resolve
IndexSettrait in the type checker - Validate mutability of root binding
- Validate field names against struct types
- Validate key and value types against
IndexSetimpl - Emit appropriate diagnostics for errors
Phase 5: Tests
- Spec tests in
tests/spec/for all cases (simple index, nested index, field, mixed chains, maps, compound assignment, errors) - Rust unit tests for parser, desugaring, and type checker changes
- Error case tests for immutable bindings, parameters, missing trait impls, type mismatches, invalid fields
Alternatives Considered
1. Add a set() Method Instead
Add list.set(index: 0, value: 42) without any syntax support.
Rejected. This solves the capability gap but not the ergonomics gap. list = list.set(index: i, value: x) is verbose and the explicit reassignment is easy to forget (writing list.set(...) without rebinding silently discards the result). Index assignment syntax makes the intent clear and prevents this class of bug.
2. Add Both set() and Index Assignment
Provide set() as the method and list[i] = x as sugar for it.
Considered. This is viable but introduces two names for the same operation (set vs updated). If both are provided, set should be documented as the underlying method and [i] = x as the preferred syntax. This proposal uses updated as the single canonical method to avoid confusion.
3. Use Index Trait with a set Method
Extend the existing Index trait with an optional set method instead of creating a new IndexSet trait.
Rejected. Not all indexable types support write. Strings implement Index but should not implement write. Separating read and write into distinct traits follows the Interface Segregation Principle and matches Rust’s Index/IndexMut separation.
4. Lenses / Optics
Use a lens-based system where index paths are first-class values that can be composed.
Deferred. Lenses are more powerful but significantly more complex to implement and explain. Index and field assignment covers the 95% use case. A lens system could be layered on top in a future proposal.
Open Questions
-
Slice assignment: Should
list[0..3] = [a, b, c]be supported? This is significantly more complex (different lengths, different types) and should be a separate proposal if desired. -
Evaluation order for nested index reads: In
list = list.updated(key: i, value: list[i].updated(key: j, value: x)),list[i]is read before the outerupdatedis called. Ifiorjinvolve side effects, the evaluation order matters. This proposal follows left-to-right evaluation, consistent with Ori’s general evaluation order.
Resolved Questions
-
Should
IndexSetbe in the prelude? Yes.Indexis in the prelude and they are complementary. Theupdatedmethod is publicly callable for users who prefer functional style. -
Should compound assignment operators (
list[i] += 1) be supported? Yes. Compound assignment composes naturally via two-step desugaring. No new traits needed.
Compatibility
- Fully backward compatible: No existing valid program changes meaning
- New syntax:
target[key] = valueandtarget.field = valuewere previously parse errors; they now have defined semantics - New trait:
IndexSetis new; no existing code can conflict with it - New method:
updatedis new on built-in types; no existing code calls it - Compound assignment: Extended to work with new assignment targets; existing compound assignment on identifiers is unchanged
References
- Ori Index Trait Proposal — existing read-only
Indextrait - Ori Custom Subscripting Proposal — original motivation for
Index - Ori spec/05-variables.md — mutability model
- Ori spec/15-memory-model.md — ARC and value semantics
- Swift Copy-on-Write — closest prior art
- Rust
IndexMuttrait — reference-based alternative - Kotlin Operator Overloading: Indexed Access — desugaring-based approach
Changelog
- 2026-02-17: Approved — expanded scope to include field assignment, mixed chains, and compound assignment; clarified type-directed desugaring; resolved open questions on prelude placement and compound operators
- 2026-02-17: Initial draft