Why Mutation Should Stay Local
Keeping mutation useful without letting it leak through the language.
The previous post explored crash-safety and explicit failure as a main design pillar of Seamless.
The next pillar is immutability: ordinary application data should be immutable by default.
Shared mutable state makes code hard to reason about. If a value can be changed from somewhere else, every use of that value has to consider what else might have happened. That is exactly the kind of uncertainty I want ordinary Seamless code to avoid.
Here is a short example of the problem:
function createSession(userId, roles) {
if (roles.some((role) => role.endsWith(":admin"))) {
throw new Error("admin sessions require step-up auth");
}
return { userId, roles };
}
function addOrganizationRoles(roles, membership) {
if (membership.owner) {
roles.push("org:admin");
}
return roles;
}
function login(user, membership) {
const baseRoles = ["app:user"];
const session = createSession(user.id, baseRoles);
const pageRoles = addOrganizationRoles(baseRoles, membership);
return { session, pageRoles };
}Can you spot the bug?
createSession checks the roles before returning. But it keeps the same array that addOrganizationRoles later mutates for the page. A role that is meant to be local to the current organization leaks into the session.
The above example is somewhat contrived, but this is exactly the pattern that happens all too easily with shared mutable references. The usual mitigations are defensive copies, convention, or careful rules about who owns a value and who may mutate it. That can work, but it is too easy to get wrong when the language does not enforce it.
What is the “correct” solution here? Should addOrganizationRoles make a copy? Should createSession? Or should it be login?
The safest answer is to avoid mutation in the first place, so instead of pushing into the same array we could do:
return membership.owner ? [...roles, "org:admin"] : roles;That is the update style many UI and state-management systems rely on: produce a new value instead of mutating the existing one in place.
But making full copies for every update is wasteful, and sometimes mutation is the right implementation tool. Efficient immutable values often use mutable structures underneath.
A string builder appends bytes into growable storage. A persistent collection update may copy a small node, patch a slot, and then publish the new immutable node.
So our goal is not to ban mutation, but to keep it local and compiler-checked.
Local Mutation
Persistent collections should be ordinary library data structures, not compiler magic.
That means the standard library, and eventually user packages, need a small mutable substrate underneath the immutable surface.
For example, an array builder can allocate mutable storage, write into it, and then freeze the initialized part into an immutable array:
region {
Array.Builder.new(2)
|> Array.Builder.push(10)
|> Array.Builder.push(20)
|> Array.Builder.freeze()
}
The result of this expression is an ordinary immutable Array(Int).
The builder does not leave the region. Only the immutable array does.
This is the basic pattern:
- use mutation to build something efficiently
- keep the mutable handles scoped
- return an immutable value
That makes useful builders possible without making mutability the default.
Regions
A region introduces a fresh lifetime identity.
Mutable values created inside the region mention that identity in their type. For example, a mutable cell has a type like this:
Kernel.Mutable.Cell(r, Int)The r means the cell belongs to region r.
Values that mention r are not allowed to escape that region.
Constructors for scoped mutable values allocate in the current region, so this is rejected:
region(r) {
Kernel.Mutable.Cell.new(0)
}
Shell.Main> region(r) { Kernel.Mutable.Cell.new(0) }
CompileError: scoped values from this region cannot escape at line 1:1 in "Shell.Main"
region(r) { Kernel.Mutable.Cell.new(0) }
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~The cell would have to leave the region as the result of the expression. That would create a mutable handle whose lifetime is no longer visible.
But this is fine:
region {
let cell = Kernel.Mutable.Cell.new(0)
Kernel.Mutable.Cell.set(cell, 41)
Kernel.Mutable.Cell.get(cell)
}
Here, the cell stays local. The result is the immutable Int returned by Kernel.Mutable.Cell.get.
Library Code
Regions also have to work across function boundaries.
So a builder function can be region-polymorphic:
@forall a
@region r
@type (Builder(r, a), a) -> Builder(r, a)
def push(builder, value) {
;; ...
}
This says: for any live region r, push can take a builder that belongs to r and return another builder that belongs to the same r.
It does not make the builder globally mutable. The caller still cannot let that builder escape its region.
This is how Kernel.Array.Builder and Kernel.String.Builder can be ordinary library code over mutable arrays and cells.
Closures
If the environment captured by a closure contains a region-scoped value, then the closure is region-scoped too.
For example:
@region r
@type (Kernel.Mutable.Cell(r, Int)) -> () @r -> Int
def make_reader(cell) {
() => Kernel.Mutable.Cell.get(cell)
}
The return type is:
() @r -> IntThat means the function value captures something from region r.
It can be called while r is alive. It cannot be treated as an ordinary region-independent () -> Int. Otherwise a lambda could smuggle a mutable cell out of the region.
This is rejected by the compiler:
region {
let cell = Kernel.Mutable.Cell.new(0)
() => Kernel.Mutable.Cell.get(cell)
}
CompileError: scoped values from this region cannot escape at line 4:5 in "Examples.Main"The region rule has to apply to higher-order code too, not only to first-order values.
Resources
Local mutation is only one use of regions.
Resources need lifetimes too: file handles, timers, HTTP requests, and callbacks retained by the host.
That is why Task carries a region:
Task(r, a, e)The task belongs to r, along with its callback and cancellation state.
When control leaves the region, including through explicit failure, the compiler emits cleanup for the resources that were actually acquired in that region. No cleanup is emitted for failed or skipped acquisitions, because the compiler tracks acquisition state.
So regions are not only about preventing mutable handles from escaping. They also give the compiler a concrete boundary where resource cleanup can be inserted.
Conclusion
Regions are the boundary that makes these pieces fit together.
Inside a region, code can use mutation, builders, callbacks, and host resources while they are useful. Outside the region, the values that survive are ordinary immutable data or explicit results, not dangling mutable handles or forgotten resources.
That is the design I want as a framework substrate: mutation and resources scoped by boundaries the compiler can check.