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

Structs & Enums

Bop supports user-defined struct and enum types, with methods attached via fn Type.method(self, ...). They give you type names in error messages, structural pattern matching, and an identity-aware equality.

Structs

A struct is a named record — a set of fields in a declared order. Field names are the part that matters; types aren’t declared.

struct Point { x, y }
struct Player { name, hp, inventory }

Create a value with TypeName { field: value, ... }:

let p = Point { x: 3, y: 4 }
print(p)                  // Point { x: 3, y: 4 }
print(p.x)                // 3
print(p.type())           // "struct"

Construction is strict: fields you provide must match the declaration exactly (no unknown fields, no duplicates, no missing ones). Extra fields or typos become parse or runtime errors with a “did you mean?” suggestion.

Field access and assignment

Read with .field, write with .field = value or any compound assignment:

let c = Counter { n: 10 }
c.n += 5                 // works
c.n *= 2                 // works
print(c.n)               // 30

The field has to already exist; assigning to an undeclared field is an error.

Passing structs

Structs follow Bop’s copy-by-value rule: passing one to a function, returning it, or assigning it to another variable makes an independent copy. Mutating the copy leaves the original alone.

fn grow(p) { p.x += 10; return p }

let a = Point { x: 1, y: 2 }
let b = grow(a)
print(a)                 // Point { x: 1, y: 2 }
print(b)                 // Point { x: 11, y: 2 }

Enums

An enum is a tagged union — one of several named variants, each with an optional payload:

enum Shape {
  Circle(r),
  Rectangle { w, h },
  Empty,
}

Variants come in three shapes:

ShapeDeclarationConstruction
UnitEmptyShape::Empty
TupleCircle(r)Shape::Circle(5)
StructRectangle { w, h }Shape::Rectangle { w: 4, h: 3 }
let a = Shape::Circle(5)
let b = Shape::Rectangle { w: 4, h: 3 }
let c = Shape::Empty
print(a.type())          // "enum"

Variants with a struct payload expose their fields via .field just like structs:

let r = Shape::Rectangle { w: 4, h: 3 }
print(r.w * r.h)         // 12

Short-name variants like enum Dir { N, E, S, W } are accepted — the case rule is “starts with an uppercase letter”, not “must contain a lowercase”.

Methods

Attach a method to a type with fn Type.method(self, ...) { ... }. The receiver arrives as the first parameter (called self by convention; any name works).

struct Point { x, y }

fn Point.sum(self)      { return self.x + self.y }
fn Point.moved(self, dx, dy) {
  return Point { x: self.x + dx, y: self.y + dy }
}

let p = Point { x: 3, y: 4 }
print(p.sum())                   // 7
print(p.moved(1, 1))             // Point { x: 4, y: 5 }

For enums, methods dispatch on the enum type — not per-variant:

enum Shape { Circle(r), Rectangle { w, h } }

fn Shape.area(self) {
  return match self {
    Shape::Circle(r)          => 3.14159 * r * r,
    Shape::Rectangle { w, h } => w * h,
  }
}

print(Shape::Circle(3).area())                  // 28.27431
print(Shape::Rectangle { w: 4, h: 3 }.area())   // 12

A user-declared method with the same name as a builtin (len, keys, etc.) wins over the builtin for receivers of that type — the precedence matches the walker, VM, and AOT.

Methods must live in the type’s own module

Methods can only be declared on structs and enums you own — that is, types declared in the same module as the method. Concretely:

  • You cannot extend a type imported from another module with new methods. If paint declares struct Color { ... }, then fn Color.brighten(self) must also live in paint, not in a consumer.
  • You cannot add methods to the built-in types (int, number, string, bool, array, dict, fn, module, iter) or to the engine-registered types Result, RuntimeError, Iter.

Declarations that violate this rule parse fine but never dispatch — the method registers against (your_module, TypeName), while values of that type carry their original home module in their identity, so lookups miss. If you catch yourself wanting to “just add a helper to Array” or “extend Result with a domain combinator,” write a free function that takes the value as an argument instead:

// Not this (declared in some consumer module):
//   fn Result.tag(self, label) { ... }   // ghost method — never fires
// Do this:
fn tag(r, label) {
  return match r {
    Ok(v)  => "{label}: ok {v}",
    Err(e) => "{label}: err {e}",
  }
}

This is the same discipline Go enforces: a type’s behaviour lives where the type is declared. It keeps dispatch coherent (no surprising overrides, no load-order dependence) at the cost of the extensions you’d get in Swift / Kotlin / Ruby.

Methods return a new value

Because values are copy-by-value, a method can’t mutate the receiver in place. Return a new instance and reassign:

fn Point.shift(self, dx, dy) {
  return Point { x: self.x + dx, y: self.y + dy }
}

let p = Point { x: 0, y: 0 }
p = p.shift(3, 4)        // p is now Point { x: 3, y: 4 }

This plays well with fluent chains:

let final = Point { x: 0, y: 0 }
  .shift(1, 0)
  .shift(0, 2)
  .shift(5, 5)

Equality

Two struct or enum values are equal when their full type identity — the module they were declared in plus the type name — and every payload matches structurally:

let p = Point { x: 1, y: 2 }
let q = Point { x: 1, y: 2 }
print(p == q)            // true (structural)

let r = Point { x: 1, y: 3 }
print(p == r)            // false (field differs)

Two types with the same name declared in different modules are distinct — see Modules.

Redeclaring the same shape is fine

Declaring the exact same struct or enum twice inside one module is a no-op — matches the “idempotent re-import” rule use already follows. Declaring two different shapes with the same name in the same module is a hard error.