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.
#Related pages
- Records and gradual types — the record type these build on.
- Functions and closures — UFCS and the closures Pattern 2 uses.
- Contracts —
whererefinements and validation in depth. - Partitions and the actor model — Pattern 3 in full.