Skip to content

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 unstable at a600b784 (PR #40) on 2026-10-11 and are not yet in a tagged release. Every program below was compiled with janus build and run on Linux x86_64, profile :core. Step 5 lists open defects next to this feature; they are defects, not supported behavior.


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 both
end
func main() -> i32 do
var boards: [2]Board = .undefined
boards[1].grid[1][1] = 21
print_int(center(&boards))
return 0
end
Terminal window
janus build center.jan center
./center

It 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.


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] = 0
end
func main() -> i32 do
var boards: [2]Board = .undefined
reset_center(&boards)
return 0
end
Terminal window
janus build reset.jan reset

The 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: InvalidCall

The 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] + 1
end
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 0
end
Terminal window
janus build tally.jan tally
./tally

It prints 2, then 7. Two write paths work:

  • An edit pointer parameter. hit writes through board and the caller sees it. Pass the element’s address: hit(&boards[1], 1, 1).
  • The owning value. boards[1].grid[0][2] = 7 writes 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.


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.len
end
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 0
end
Terminal window
janus build walk.jan walk
./walk

It 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.


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 0
end

It 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).


ActThrough a let pointer bindingWhere it belongs instead
Borrow &p.field[i][j]accepted–
Read p.field[i][j]accepted–
Write p.field[i][j] = vrefused: 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.