Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

@immutable Annotation

The @immutable annotation enforces immutability constraints on types. Once an object is created, its fields cannot be modified.

Motivation

Go doesn't provide a built-in way to declare types or fields as immutable. This contrasts with many functional programming techniques that rely on immutability to provide additional stability guarantees and reduce risks in concurrent code.

Use cases:

  • Messages sent through channels
  • HTTP request/response objects
  • Configuration objects
  • Value objects in domain models

The @immutable annotation fills this gap by providing compile-time enforcement of immutability.

Syntax

// @immutable
type TypeName struct {
    // fields
}

No parameters required - simply add // @immutable above the type declaration.

@mutable Field Exceptions

Specific fields in immutable types can be marked as mutable to allow runtime modifications like cache updates or state changes.

// @immutable
type CachedUser struct {
    ID    int64
    Name  string

    // @mutable
    Cache map[string]interface{}
}

How It Works

GoGreement detects the following violations on immutable types:

  1. Field assignments: obj.field = value
  2. Compound assignments: obj.field += value, obj.field -= value, etc.
  3. Increment/decrement: obj.field++, obj.field--
  4. Index assignments: obj.items[0] = value, obj.dict["key"] = value
  5. Receiver operations in methods: For methods on immutable types:
    • *receiver = value (receiver reassignment)
    • *receiver++, *receiver-- (receiver increment/decrement)

Key Behaviors

  1. No generics support: Cannot be used with generic types
  2. Weak immutability guarantees:
    • Prevents field assignments and compound operations
    • Prevents assignments through methods
    • Does NOT prevent mutations through pointers or reflection
  3. Constructor exception: Checks are ignored inside functions marked with @constructor
  4. @mutable field exceptions: Fields marked with @mutable can be modified even in immutable types
  5. Can be suppressed: Use @ignore to disable checks in specific scopes
  6. Cross-package enforcement: Works even if @immutable was declared in external modules

Can Be Declared On

Struct Types

// @immutable
type Point struct {
    X, Y int
}

Interface Types

// @immutable
type ReadOnlyConfig interface {
    GetValue(key string) string
}

Named Types

// @immutable
type UserID string

// @immutable
type StatusCode int

Error Codes

CodeDescriptionExample
IMM01Field assignmentpoint.X = 10
IMM02Compound assignmentpoint.X += 5, point.Y *= 2
IMM03Increment/decrementpoint.X++, count--
IMM04Index assignmentobj.items[0] = value, obj.dict["key"] = value

Examples

✅ Basic Immutable Type

// @immutable
// @constructor NewPoint
type Point struct {
    X, Y int
}

func NewPoint(x, y int) Point {
    return Point{X: x, Y: y}  // ✅ Allowed in constructor
}

func DoublePoint(p Point) Point {
    // ✅ Correct: Create new instance instead of mutating
    return Point{X: p.X * 2, Y: p.Y * 2}
}

✅ Immutable Configuration

// @immutable
// @constructor LoadConfig
type Config struct {
    Host string
    Port int
    TLS  bool
}

func LoadConfig(path string) (*Config, error) {
    cfg := &Config{  // ✅ Allowed in constructor
        Host: "localhost",
        Port: 8080,
        TLS:  false,
    }
    return cfg, nil
}

❌ Field Assignment

// @immutable
type Point struct {
    X, Y int
}

func MovePoint(p *Point) {
    p.X += 10  // ❌ error: [IMM02] immutability violation in type "Point": cannot use += on field "X" of immutable type (outside constructor)
    p.Y = 20   // ❌ error: [IMM01] immutability violation in type "Point": cannot assign to field "Y" of immutable type
}

❌ Increment/Decrement

// @immutable
type Counter struct {
    value int
}

func Increment(c *Counter) {
    c.value++  // ❌ error: [IMM03] immutability violation in type "Counter": cannot use ++ on field "value" of immutable type (outside constructor)
}

func Decrement(c *Counter) {
    c.value--  // ❌ error: [IMM03] immutability violation in type "Counter": cannot use -- on field "value" of immutable type (outside constructor)
}

❌ Index Assignment

// @immutable
type Data struct {
    items []int
    dict  map[string]int
}

func Modify(d *Data) {
    d.items[0] = 42          // ❌ error: [IMM04] immutability violation in type "Data": cannot modify element of field "items" of immutable type
    d.dict["key"] = 100      // ❌ error: [IMM04] immutability violation in type "Data": cannot modify element of field "dict" of immutable type
}

✅ Using @ignore to Suppress

// @immutable
type Cache struct {
    data map[string]string
}

func (c *Cache) Update(key, value string) {
    // @ignore IMM04
    c.data[key] = value  // ✅ Suppressed via @ignore
}

✅ Cross-Package Immutability

Package models:

package models

// @immutable
type User struct {
    ID   int
    Name string
}

Package main:

package main

import "myapp/models"

func updateUser(u *models.User) {
    u.Name = "New Name"  // ❌ ERROR: IMM01 - User is immutable (from external package)
}

✅ Using @mutable Fields

// @immutable
// @constructor NewCachedUser
type CachedUser struct {
    ID    int64     // ❌ Cannot be modified
    Name  string    // ❌ Cannot be modified

    // @mutable
    Cache map[string]interface{}  // ✅ Can be modified
}

func UpdateCache(user *CachedUser, key string, value interface{}) {
    user.Cache[key] = value  // ✅ Allowed - Cache is @mutable
}

func UpdateName(user *CachedUser, name string) {
    user.Name = name  // ❌ ERROR: IMM01 - Name is not @mutable
}

✅ Correct Pattern: Return New Instances

// @immutable
// @constructor NewPerson
type Person struct {
    Name string
    Age  int
}

func NewPerson(name string, age int) Person {
    return Person{Name: name, Age: age}
}

// ✅ Correct: Return modified copy
func WithAge(p Person, newAge int) Person {
    return Person{
        Name: p.Name,
        Age:  newAge,
    }
}

// ✅ Correct: Builder pattern for construction
func (p Person) WithName(name string) Person {
    return Person{Name: name, Age: p.Age}
}

❌ Receiver Operations in Methods

// @immutable
// @constructor NewCounter
type Counter struct {
    value int
}

func NewCounter() Counter {
    return Counter{value: 0}
}

func (c *Counter) Increment() {
    c.value++  // ❌ [IMM03] cannot use ++ on field "value" of immutable type (outside constructor)
}

func (c *Counter) Reset(newVal int) {
    *c = Counter{value: newVal}  // ❌ [IMM01] cannot reassign immutable receiver (outside constructor)
}

func (c *Counter) Decrement() {
    (*c)--  // ❌ [IMM03] cannot use -- on immutable receiver (outside constructor)
}

Best Practices

1. Combine with @constructor

Always use @constructor with @immutable to control object creation:

// @immutable
// @constructor NewConfig
type Config struct {
    value string
}

func NewConfig(v string) Config {
    return Config{value: v}
}

2. Return New Instances

Instead of mutating, return new instances:

// ❌ Bad: Mutation
func UpdateName(u *User) {
    u.Name = "newname"  // ERROR
}

// ✅ Good: Return new instance
func WithName(u User, name string) User {
    return User{ID: u.ID, Name: name}
}

3. Use for Value Objects

Immutable types work well for value objects:

// @immutable
// @constructor NewMoney
type Money struct {
    Amount   int
    Currency string
}

func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, errors.New("currency mismatch")
    }
    return Money{
        Amount:   m.Amount + other.Amount,
        Currency: m.Currency,
    }, nil
}

5. Document Immutability Intent

// @immutable
// Point represents an immutable 2D coordinate.
// Use NewPoint to create instances and helper functions to derive new points.
type Point struct {
    X, Y int
}

Limitations

Weak Guarantees

@immutable provides weak immutability - it prevents direct field assignments but doesn't prevent:

  • Pointer manipulation: Modifying through unsafe pointers
  • Reflection: Mutations via reflect package
  • Slice/map element mutations: Modifying elements inside slices or maps (only index assignment is caught)
// @immutable
type Data struct {
    items []Item  // The slice itself can't be reassigned, but elements can be modified
}

func modify(d Data) {
    d.items = nil           // ❌ ERROR: IMM01 - Assignment
    d.items[0] = newItem    // ❌ ERROR: IMM04 - Index assignment
    d.items[0].field = 123  // ✅ No error - element field modification not caught
}

Workaround

For stronger guarantees, use unexported fields with exported getter methods:

// @immutable
type SafeData struct {
    items []Item  // unexported - can't be accessed directly
}

func (d SafeData) Items() []Item {
    // Return copy to prevent external modifications
    result := make([]Item, len(d.items))
    copy(result, d.items)
    return result
}

See Also