Borrowing Through Pointer Bindings
A let binding that holds an address is for looking and borrowing.
Writes go where a signature says edit.
Time: 25 minutes
Level: Intermediate
Prerequisites: Fundamentals, parameter intents in the
Language Reference §4.6
What you’ll build: a board reader that borrows cells through let
bindings, a writer that declares its mutation, and a parsed-document walker
Status: borrowing through pointer bindings (Gap 205) and the chained indexing repair (review finding F-91) merged to
unstableata600b784(PR #40) on 2026-10-11 and are not yet in a tagged release. Every program below was compiled withjanus buildand run on Linux x86_64, profile:core. Step 5 lists open defects next to this feature; they are defects, not supported behavior.
Why this matters
Section titled “Why this matters”Parsers, indexes and game boards hand you nested data you do not own. The
natural way to inspect it is to bind an address and look through it:
let root = &doc.nodes[0], then &root.children[i]. Before PR #40 the
compiler treated that second borrow as a write to root and refused it with
cannot assign to immutable binding 'root'. A traversal that only looks was
refused as if it touched.
Now the compiler separates the two acts. Taking an address through a let
binding is a borrow. Assigning a field through it is a write, and an
immutable binding still refuses it.
Step 1: Borrow and read through a binding (5 min)
Section titled “Step 1: Borrow and read through a binding (5 min)”Create center.jan:
struct Board { grid: [3][3]i32}
func center(boards: *[2]Board) -> i32 do let board = &boards[1] // a let binding holding an address let cell = &board.grid[1][1] // borrow through it, chained indexing return board.grid[1][1] + cell.* // read through bothend
func main() -> i32 do var boards: [2]Board = .undefined boards[1].grid[1][1] = 21 print_int(center(&boards)) return 0endjanus build center.jan center./centerIt prints 42. board holds the address of boards[1]; cell holds the
address of one cell inside it, reached with two indexes after a field. Both
reads go to the caller’s array. Nothing was copied.
Step 2: Watch a write get refused (3 min)
Section titled “Step 2: Watch a write get refused (3 min)”Create reset.jan, which tries to write through the same kind of binding:
struct Board { grid: [3][3]i32}
func reset_center(boards: *[2]Board) do let board = &boards[1] board.grid[1][1] = 0end
func main() -> i32 do var boards: [2]Board = .undefined reset_center(&boards) return 0endjanus build reset.jan resetThe build fails and no binary is written. The compiler prints the full source path in front of the location; it is shortened to the file name here:
reset.jan:7:5: error: cannot assign to immutable binding 'board'Lowering error: InvalidCallThe refusal holds at every index depth after a field: root.name = v,
row.cells[1] = v and board.grid[1][1] = v are all writes through the
binding. Before PR #40 the two-index form slipped past this check.
Step 3: Write where the signature says so (5 min)
Section titled “Step 3: Write where the signature says so (5 min)”A function that mutates should say it does. Give it an edit pointer
parameter. Create tally.jan:
struct Board { grid: [3][3]i32}
func hit(edit board: *Board, row: usize, col: usize) do board.grid[row][col] = board.grid[row][col] + 1end
func center(boards: *[2]Board) -> i32 do let board = &boards[1] return board.grid[1][1]end
func main() -> i32 do var boards: [2]Board = .undefined boards[1].grid[1][1] = 0 hit(&boards[1], 1, 1) hit(&boards[1], 1, 1) boards[1].grid[0][2] = 7 print_int(center(&boards)) print_int(boards[1].grid[0][2]) return 0endjanus build tally.jan tally./tallyIt prints 2, then 7. Two write paths work:
- An
editpointer parameter.hitwrites throughboardand the caller sees it. Pass the element’s address:hit(&boards[1], 1, 1). - The owning value.
boards[1].grid[0][2] = 7writes straight into the local array. Before PR #40 this chained write could land in a temporary and vanish; it now lands.
Prefer edit over an unqualified pointer parameter. An unqualified
parameter is view by default, and only an explicit view rejects
mutation (E2702). Mutation through an unqualified parameter is still
exempted for legacy code. See
Language Reference §4.6.
Step 4: Walk a parsed document (5 min)
Section titled “Step 4: Walk a parsed document (5 min)”This is the shape that motivated the repair: walking nodes owned by a
parser in another module. Create walk.jan:
use std.encoding.kdl
func list_children(doc: *const kdl.KdlDocument) -> usize do let root = &doc.nodes[0] var i: usize = 0 while i < root.children.len do let child = &root.children[i] println(child.name) i = i + 1 end return root.children.lenend
func main() -> i32 do var doc = kdl.parse("system { target; host; }\n") catch return 2 let count = list_children(&doc) kdl.deinit(&doc) if count != 2 do return 1 end return 0endjanus build walk.jan walk./walkIt prints target, then host, and exits 0. A compiler from before
PR #40 refuses this file at &root.children[i] with
cannot assign to immutable binding 'root'. Renaming a node through the
same binding (root.name = "other") is still refused.
Step 5: Know the edges (5 min)
Section titled “Step 5: Know the edges (5 min)”Three open compiler defects sit next to this feature. Each was reproduced on the same compiler as the steps above.
var pointer bindings lose writes (Gap 208). With a local array,
var board = &boards[1] followed by board.grid[1][1] = 9 compiles and
runs, but boards[1].grid[1][1] keeps its old value. When the array sits
behind a pointer parameter, the same change fails in code generation with
LLVM emit error: MissingOperand. Do not switch let to var to silence
the immutable-binding error. Write through an edit parameter or through
the owning value, as in step 3.
Chained indexing through a slice (Gap 223). When rows[i] is a slice
(rows: [2][]i32 or rows: [][]i32), rows[i][j] reads the slice header
instead of the element, so it reads 0, and a write through it crashes.
Bind the inner slice first. Create rows.jan:
func main() -> i32 do var backing: [4]i32 = .undefined backing[2] = 6 var rows: [2][]i32 = .undefined rows[1] = backing[0..4] let row = rows[1] // bind the inner slice first row[3] = 7 // write lands in backing[3] print_int(row[2]) print_int(backing[3]) return 0endIt prints 6, then 7. A slice of arrays (rows: [][2]i32) is affected
too: rows[1][0] = 9 is lost and reading rows[1][0] returned a wrong
value. No workaround is verified for that shape; avoid chained indexing on
a slice of arrays until Gap 223 is fixed.
What a let binding does not protect. The refusal covers assignment
through a field of the pointee. Two spellings compile and write: an explicit
pointee store (let cell = &board.grid[1][1] then cell.* = 9) and an
index store on a binding that holds an array pointer (let row = &grid[1]
then row[0] = 9). A let binding is not a read-only view. For enforced
read-only access use an explicit view parameter, and note its own open
hole: a .* store through an address derived from a view parameter
compiles and writes (Gap 187).
What you learned
Section titled “What you learned”| Act | Through a let pointer binding | Where it belongs instead |
|---|---|---|
Borrow &p.field[i][j] | accepted | – |
Read p.field[i][j] | accepted | – |
Write p.field[i][j] = v | refused: cannot assign to immutable binding 'p' | edit pointer parameter, or the owning value |
Write via var p = &... | compiles, write lost (Gap 208) | edit pointer parameter |
Next: practice the same rules in the Monastery mission
05-borrowed-address/01-borrow-through-a-binding, and read the
pointer bindings reference.