SCUA

Manual

Objects and OOP patterns

SCUA has no class keyword. If you're coming from a class-based language that can feel like something's missing — but it usually isn't. What a class bundles together, SCUA gives you as separate, composable pieces, and most of the time you already have everything you reach a class for. This page shows the idioms.

Here's the decomposition. A class is really several things at once:

What a class gives you In SCUA
Fields with a fixed shape a record (with defaults)
A constructor a record literal, or a factory function
Methods (obj.method()) free functions typed on the receiver (their first parameter)
Invariants / validation a where refinement on the record
Private state a closure that captures it
A stateful thing with identity a partition (an actor)

There's no single "object" construct because these don't always travel together, and splitting them keeps each one simple. Below are the three patterns that cover essentially every "I want an object" case.

#Pattern 1 — a record and some functions

The everyday case: data with a fixed shape, plus behaviour that operates on it.

record Player {
  name:  string,
  hp:    int = 100,
  level: int = 1,
}

-- Behaviour is plain functions whose first parameter is the instance. Typing that first parameter as
-- the record is what makes the function a *method* of it (see "Calling the methods" below).
fn damage(p: Player, amount: int) p.hp = p.hp - amount end
fn is_alive(p: Player)            return p.hp > 0 end
fn describe(p: Player) -> string  return `{p.name} (lvl {p.level}, {p.hp} hp)` end

#Constructing one

A record literal builds an instance; missing fields with defaults are filled in. The type annotation is what triggers that filling and checks the shape:

let hero: Player = { name = "Ed" }          -- hp = 100, level = 1 filled in
let boss: Player = { name = "Dragon", hp = 500, level = 40 }

When construction needs logic, wrap it in a factory function — that's your "constructor". Build the instance with an annotated let inside, then return it:

fn new_player(name)
  let p: Player = { name = name }   -- sealed + defaulted here
  return p
end

(Construct via an annotated let, not by returning a bare {…} with a -> Player signature — the return boundary won't fill defaults for you, so that's a type error. And there's no Player{…} literal form; the type goes on the binding.)

#Calling the methods

Call them method-style with a dot. p.method(args) is exactly method(p, args) — the instance is just the first parameter; there's no hidden self. A function counts as a method of a record when its first parameter is typed with that record:

let p: Player = { name = "Ed" }
p.damage(30)               -- method call: damage(p, 30), fully type-checked
print(p.describe())        -- "Ed (lvl 1, 70 hp)"
print(p.is_alive())        -- true

Because the methods are typed against Player, the checker verifies the call — wrong argument types or counts are caught before the program runs, and p.frobnicate() (no such method or field) is an error. The instance keeps its Player type throughout.

This is the same dot you'd reach for in a class-based language, but the pieces stay separate and honest: the data is a record, the methods are ordinary functions, and "method of Player" means nothing more than "function whose first parameter is a Player." So a method also works as a plain call (damage(p, 30)), and a function over a shared shape — say anything with an hp field — is reusable across record types. No class, no inheritance, no hidden receiver.

(On an untyped instance the same dot still works, just unchecked — that's the gradual default. Typing the instance is what turns on the checking.)

#Pattern 2 — a closure object (private state)

Pattern 1's record fields are public. When you need fields that genuinely can't be read or written from outside, return a record of functions that close over local state. This is the classic closure-as-object — reach for it specifically when encapsulation matters, not just to get methods (Pattern 1 already gives you those). The methods are callable fields, and the captured state has no name anyone outside can reach:

fn make_account(opening)
  let bal = opening          -- private: nothing outside can see `bal`

  return {
    deposit  = fn(amt) bal = bal + amt return bal end,
    withdraw = fn(amt)
      if amt > bal then return Error("insufficient funds") end
      bal = bal - amt
      return Ok(bal)
    end,
    balance  = fn() return bal end,
  }
end

let acc = make_account(100)
print(acc.deposit(50))               -- 150
match acc.withdraw(40)
  Ok(b)    -> print(`balance {b}`)   -- "balance 110"
  Error(e) -> print(`denied: {e}`)
end

Note the difference from Pattern 1: here deposit is a function stored in acc, so acc.deposit(50) calls that function with (50) — it does not pass acc, because the closure already captured what it needs. A callable field wins over UFCS. So Pattern 1's methods take the instance explicitly; Pattern 2's methods capture it. Reach for this one when encapsulation matters.

#Pattern 3 — a partition (a stateful active object)

When the "object" is a long-lived thing with its own mutable state that you want isolated and message-driven, that's a partition. Creating one is your constructor; state fields are its private memory; handlers are its methods:

partition Counter
  state n = 0
  on Inc(by)
    n = n + by
    print(`count is now {n}`)
  end
  on Reset()
    n = 0
    print("count reset")
  end
end

let c = Counter()     -- the "constructor" — a fresh, isolated instance
tell c.Inc(3)         -- a method call (fire-and-forget): "count is now 3"
tell c.Inc(4)         -- "count is now 7"
tell c.Reset()        -- "count reset"

tell sends a one-way message. For request/response — a method that returns a value — a partition uses ask against another partition from inside a handler (you can't ask from the top level). See Partitions and the actor model for the full story. Use a partition when you need isolation, concurrency-safety, or persistence; use Pattern 1 or 2 for ordinary in-memory objects.

#Construction that validates itself

This is where SCUA goes past what a class gives you. A where refinement on a record makes the type enforce its own invariant, checked automatically whenever data crosses into it — at a typed binding, a typed parameter, a persistence load. You can't build an invalid instance and forget to validate it, because the validation isn't something you remember to call:

record Account {
  owner:   string,
  balance: int where balance >= 0,
}

let ok: Account = { owner = "Ed", balance = 100 }   -- fine
let bad: Account = { owner = "X", balance = -5 }     -- faults at this line

The bad line faults with a located message naming the field and value (field 'balance' of Account violates its 'where' constraint (got -5)). For a reusable, named rule, define a contract and attach it: balance: int where NonNeg. See Contracts for the full refinement story.

#No inheritance — compose instead

SCUA has no inheritance and no metatables. Shared structure is composition: put a record inside a record and delegate with free functions.

record Vec2   { x: int, y: int }
record Entity { pos: Vec2, name: string }

fn moved(e, dx, dy)
  let np: Vec2  = { x = e.pos.x + dx, y = e.pos.y + dy }
  let r:  Entity = { pos = np, name = e.name }
  return r
end

let rock = { pos = { x = 1, y = 2 }, name = "rock" }
let r2 = moved(rock, 10, 5)
print(`{r2.name} at {r2.pos.x},{r2.pos.y}`)   -- "rock at 11,7"

Shared behaviour is just functions that work on anything with the right fields — with gradual typing, a function that reads e.hp works on any value that has an hp, no base class required. This sidesteps the fragile-base-class problems that make deep hierarchies hard to change.

#Same method name on different types

A method name isn't owned by one type. You can define area for a Circle and area for a Rect, and the right one is chosen by the value's actual type — at the call, by you, automatically:

record Circle { r: int }
record Rect   { w: int, h: int }
fn area(c: Circle) -> int return c.r * c.r end
fn area(s: Rect)   -> int return s.w * s.h end

let c: Circle = { r = 4 }
let s: Rect   = { w = 3, h = 5 }
print(c.area())   -- 16  (Circle's)
print(s.area())   -- 15  (Rect's)

This is dispatch on the receiver — the same idea as overriding a method in a class hierarchy, but without the hierarchy. The dispatch is on the value's real type at runtime, so it works even when the static type isn't known (an untyped value, or an interface — below).

#Interfaces — one function over many types

When you want to write a function that works across any type with certain behaviour, declare an interface: a set of required methods. A type satisfies it just by having those methods — there's nothing to declare on the type, no implements:

interface Shape {
  fn area(self) -> int
}

fn total(shapes: { Shape }) -> int     -- any array of things that are Shapes
  let sum = 0
  for sh in shapes do sum = sum + sh.area() end   -- each dispatched to its real type
  return sum
end

print(total([c, s]))   -- 31  (Circle and Rect, mixed freely)

Circle and Rect satisfy Shape because each has an area method — that's the whole contract. A function taking a Shape accepts any of them, and inside it the value is opaque except for the interface's methods (sh.area() is fine; sh.r is not — total shouldn't depend on a concrete shape). Pass a type that's missing a required method and it's a compile-time error. Interfaces are erased — they're a checking tool, with no runtime cost beyond the dispatch you'd pay anyway.

This is the closest thing SCUA has to "an abstract type many concretes implement" — structural, like Go's interfaces, and without inheritance.

#Which pattern?

  • Pattern 1 (record + functions) — the default for plain data objects. Reach for it first.
  • Pattern 2 (closure object) — when you need true private state and obj.method() syntax together.
  • Pattern 3 (partition) — when the object is a stateful, long-lived, isolated thing with a message interface.

If you find yourself wishing for a class, it's almost always one of these three — usually Pattern 1.