Golang Immutable Structs — Immutability by Default for Go

Mutation is the single largest source of bugs in concurrent and large-scale programs. Race conditions, unexpected side effects, defensive copying — all stem from mutable state. Go has const for compile-time constants, but nothing to prevent runtime mutation of variables, struct fields, or pointed-to data. GALA eliminates these problems by making golang immutable structs the default — at every level: variable bindings, struct fields, and even pointers.


Why Immutability Matters

Go has const for compile-time constants, but nothing to prevent runtime mutation of variables, struct fields, or pointed-to data. GALA fills that gap.


val vs var

GALA distinguishes immutable and mutable bindings at declaration:

val x = 10      // immutable — cannot be reassigned
var y = 20      // mutable — can be reassigned
y = 30          // OK
// x = 20       // compile error: cannot assign to immutable variable

Multiple declarations work too:

val a, b = 1, 2
var x, y int = 10, 20

Short Declarations Are Immutable

The := operator creates an immutable binding, just like val:

func main() {
    z := 40
    // z = 50   // compile error: cannot assign to immutable variable
}

This is a deliberate departure from Go, where := creates a mutable variable. In GALA, you must explicitly opt in to mutation with var.

Exception: Loop variables declared with := in a for init statement are mutable so that i++ works:

for i := 0; i < 10; i++ {
    Println(i)    // i is mutable here
}

Immutable Struct Fields

Struct fields are immutable by default. Use var to opt in to mutability:

Shorthand Syntax

struct Person(Name string, Age int, var Score int)
// Name and Age are immutable
// Score is mutable

Block Syntax

type Config struct {
    Host string       // immutable
    Port int          // immutable
    var MaxConns int  // mutable
}

Attempting to assign to an immutable field produces a compile error. This is enforced at the transpiler level — the generated Go code wraps immutable fields in Immutable[T], a container with a Get() method but no Set().


Copy() with Named Arguments

Every GALA struct gets a Copy() method for free. It creates a new instance with optional field overrides:

struct Config(Host string, Port int)

val base = Config("localhost", 3000)
val updated = base.Copy(Port = 8080)
// updated is Config("localhost", 8080)
// base is unchanged

The idiomatic Go equivalent copies the struct value, then mutates the field in place:

GALAGo
struct Config(Host string, Port int)
val updated = config.Copy(Port = 8080)
type Config struct {
    Host string
    Port int
}
updated := config
updated.Port = 8080

Go’s copy is concise, but updated and config both stay mutable, and if Config holds a slice or map the value copy shares it. GALA’s Copy() returns a new immutable value as an expression – no temp variable, no in-place mutation, and the original val can never be reassigned. It also composes: you can drop config.Copy(Port = 8080) straight into a function call or pipeline.


Equal() — Structural Equality for Free

Every GALA struct also gets an Equal() method that performs deep structural comparison of all fields:

struct Person(Name string, Age int)

val p1 = Person("Alice", 30)
val p2 = Person("Alice", 30)
val same = p1.Equal(p2)    // true

No need to implement equality by hand. No risk of forgetting a field.


A val Has No Address

&x is a pointer, and a pointer can write. So a val, a parameter, a receiver and an immutable field have no address: taking one is GALA-E0076. To read a value without changing it, pass the value itself; it is already immutable.

val data = 42
val ptr = &data       // error: GALA-E0076

The address of a var can be taken, and writes go through it:

package main

func main() {
    var data = 42
    val ptr = &data       // Ptr[int]: never nil, an *int in the generated Go
    *ptr = 100            // writes data through the pointer
    Println(data)         // 100
}

Pointers That Are Never Nil: Ptr[T] and OptionPtr[T]

GALA has three pointer types:

Type Can be nil Use
Ptr[T] never a pointer created and used in GALA code; &x is one
OptionPtr[T] it is empty instead a pointer that may be absent
*T yes Go interop: what Go functions take and return

Ptr[T] is checked at compile time and compiles to a plain Go *T, so it costs nothing at run time. It has the fields and methods of what it points to, can be a method receiver, and passes to Go wherever a *T is expected. The other direction is checked: nil, or a Go *T that may be nil, never fills a Ptr, and comparing a Ptr with nil is an error (GALA-E0080).

package main

struct Counter(var N int)

func (c Ptr[Counter]) Bump() {
    c.N = c.N + 1
}

func main() {
    var c = Counter(0)
    val p = &c      // Ptr[Counter]: never nil; *Counter in the generated Go
    p.Bump()
    p.Bump()
    Println(c.N)    // 2
}

OptionPtr[T] is a pointer that may be absent, the size of one pointer. It matches as Some(p) / None(), with p a Ptr[T], and a Go pointer is converted once, where it enters GALA code: OptionPtrOf(p) when nil means absent, RequirePtr(p) when nil is a bug.

package main

import "flag"

struct Node(Value int, Next OptionPtr[Node])

func sum(n Ptr[Node]) int = n.Next match {
    case Some(next) => n.Value + sum(next)
    case None()     => n.Value
}

func main() {
    val list = &Node(1, SomePtr(&Node(2, SomePtr(&Node(3, EmptyOptionPtr())))))
    Println(sum(list))        // 6

    // flag.Lookup returns a Go *flag.Flag that is nil when the flag is unknown
    val port = OptionPtrOf(flag.Lookup("port"))
    Println(port.IsEmpty())   // true
}

Exported functions check their Ptr parameters for nil on entry, so a nil passed from Go code is caught at the boundary. The full rules are in the language reference.


When to Use var

Immutability is the default, but mutation is sometimes the right choice:

Mutable collections — When you need in-place updates for performance:

import . "martianoff/gala/collection_mutable"

val m = EmptyHashMap[string, int]()
m.Put("a", 1)    // modifies in place
m.Put("b", 2)

Accumulators — A running total is the classic reason to reach for var in Go. In GALA, a fold usually carries the running value for you, so check for one before declaring a var and a loop:

val total = items.FoldLeft(0, (acc, v) => acc + v)

Pointer fields in linked structures — When a link is set after construction:

struct Node(Value int, var Next OptionPtr[Node])   // var: the link is set after construction

The rule of thumb: start with val and immutable fields. Switch to var only when you have a concrete reason.


Immutability at Every Level

GALA enforces immutability at three levels:

Level Immutable (default) Mutable (opt-in)
Variable bindings val x = 10, x := 10 var x = 10
Struct fields Name string var Name string
Pointers no address for a val (&val is an error) Ptr[T] (from &var, never nil)

This layered approach means immutability is not just a convention — it is enforced by the compiler at every point where data flows through your program.


Further Reading