Classes and Traits

A class represents a concept from your program's domain. It groups that concept's data (its fields) with its behavior (its methods) and encapsulates the data to protect the class's invariants, the rules that must always hold.

Each instance of a class, called an object, stores values for those fields.

A trait is behavior that many classes share, written once and implemented by each of them.

Classes and objects are primitives in Luwu, like numbers and strings. type() returns "class" for a class and "object" for an object.

From metatables to classes

In Luau, objects are usually tables with a metatable. Each one needs some setup code:

const Account = {}
Account.__index = Account

function Account.new(owner: string)
    return setmetatable({ owner = owner, balance = 0 }, Account)
end

export type Account = typeof(Account.new(...))

function Account.deposit(self: Account, amount: number)
    self.balance += amount
end

const account = Account.new("Ada")
account:deposit(50)
print(account.balance)

That's well-typed Luau. Its type comes from typeof(Account.new(...)), and each method takes self: Account by hand.

In Luwu, the same thing is a class:

class Account(owner: string)
    balance = 0

    function deposit(self, amount: number)
        self.balance += amount
    end
end

const account = Account("Ada")
account:deposit(50)
print(account.balance)

Type checking

Metatables are hard for the type checker to follow, and types and autocomplete often break on them. With a class, the checker knows every field and method of every object, and a misspelled field is a type error instead of a silent nil.

Classes and traits are nominally typed: what matters is the name, not the shape. Two classes with the same fields are still different types: neither can be passed where the other is expected. A trait's name is a type too, and an object of any class that implements the trait fits it.

Metatables still work. Luwu types tables with metatables structurally like Luau and checks them better through improvements like type references and method calls on union types.

Performance

Classes are also faster than metatable objects. Objects take less memory than tables, and reading or writing a field skips the key lookup a table needs. At --!optimize 2, Luwu can also copy small methods straight into the code that calls them.

See Performance Innovations →

Inheritance through traits

Upstream Luau is adding classical inheritance through open class and extends. Luwu takes the useful parts of inheritance and calls them traits. A trait gives classes shared methods with default implementations they can override, lists members each class is expected to provide, and can require other traits.

Luwu leaves out two parts of classical inheritance. Building an object only ever runs the constructor of its class. There's no is-a identity either: an object belongs to exactly one class, and class.isinstance checks that class alone. class.implements answers the separate question of whether an object has some trait's behavior. Traits are covered further down.

Classes

Your first class

The parameters after the class name make up its primary constructor and become fields of the object.

Functions that take self are methods. You call them with :.

class Dog(name: string, age: number)
    function bark(self)
        print(`{self.name} barked!`)
    end
    function birthday(self)
        self.age += 1
    end
end

const oreo = Dog("Oreo", 3)
oreo:bark()
oreo:birthday()
print(oreo.age)

Calling a class like a function builds a new object. Passing it the wrong number or type of arguments is a type error.

Plain old data (POD) classes

Some classes only hold data, like a named table or a struct. Luwu calls these plain old data (POD) classes. Give each field a default value and leave out the constructor.

Then build objects from a table with the POD constructor. Fields you leave out get their defaults.

class Settings
    volume = 0.8
    fullscreen = false
    player_name = "Player"
end

const defaults = Settings()
const custom = Settings { fullscreen = true, player_name = "Ada" }
print(defaults.volume, custom.fullscreen, custom.player_name)

Default values are created fresh for every object. Two objects never share a default table by accident.

class Inventory
    items: { string } = {}
end

const a = Inventory()
const b = Inventory()
table.insert(a.items, "sword")
print(#a.items, #b.items)

Static functions

A function without self belongs to the class itself. Call it with a dot.

Static functions are a good place for alternative ways to build an object.

class Temperature(celsius: number)
    function from_fahrenheit(fahrenheit: number): Temperature
        return Temperature((fahrenheit - 32) * 5 / 9)
    end
    function describe(self): string
        return `{self.celsius}°C`
    end
end

print(Temperature.from_fahrenheit(212):describe())

Computed fields

A field's default can use the constructor's parameters. That's how you clean up or derive data on the way in.

class Username(raw: string)
    text = string.lower((string.gsub(raw, "%s+", "_")))
end

const name = Username("Ada Lovelace")
print(name.text, name.raw)

The parameter stays a field too: name.raw is still there.

Constructors with __init

Add an __init function when building an object needs custom logic. Luwu creates the object, then calls __init with it and your arguments. Reach for it when:

This point is built from text like "3,4". It keeps x and y, but not the text:

class Point
    x: number
    y: number

    function __init(self, text: string)
        const x, y = string.match(text, "^(%-?%d+),(%-?%d+)$")
        self.x = tonumber(x) or 0
        self.y = tonumber(y) or 0
    end
end

const point = Point("3,4")
print(point.x, point.y)

A class has either a primary constructor or __init, not both.

Public, private and const

Mark a member private to keep it inside the class. Code outside the class can't read or change it: trying is a type error and an error at runtime.

Mark a field const when it should never change after the object is built.

Once one member has public or private, every member needs one.

class BankAccount(
    public const owner: string,
    private balance: number = 0
)
    public function deposit(self, amount: number)
        if amount > 0 then
            self.balance += amount
        end
    end
    public function get_balance(self): number
        return self.balance
    end
end

const account = BankAccount("Ada")
account:deposit(50)
account:deposit(-10)
print(account.owner, account:get_balance())

Because only the class's methods can change the balance, every deposit goes through the same check.

Protecting invariants

Some data is only valid when certain properties always hold: health stays between 0 and its maximum, an age is never negative, a position is never NaN. Those properties are the class's invariants, and encapsulation is how a class protects them. When the fields are private and every change goes through the class's methods, no code outside it can leave the data in an invalid state.

This Health class represents a player's health points. Its constructor and current are private, every change goes through clamped, and its metamethods let you use +, -, == and < on it directly. Printing it draws a healthbar:

const colors = require("@std/io/colors")

const EMPTY = vector.create(243, 139, 168)
const FULL = vector.create(166, 227, 161)

class Health private (
    private current: number,
    public const max_health: number
)
    public function full(max_health: number): Health
        return Health(max_health, max_health)
    end
    private function clamped(current: number, max_health: number): Health
        if math.isnan(current) then
            error("health can't be NaN", 3)
        end
        return Health(math.clamp(current, 0, max_health), max_health)
    end
    public function __add(self, amount: number): Health
        return Health.clamped(self.current + amount, self.max_health)
    end
    public function __sub(self, amount: number): Health
        return Health.clamped(self.current - amount, self.max_health)
    end
    public function __eq(self, other: Health): boolean
        return self.current == other.current
    end
    public function __lt(self, other: Health): boolean
        return self.current < other.current
    end
    public function __le(self, other: Health): boolean
        return self.current <= other.current
    end
    public function __tostring(self): string
        const ratio = self.current / self.max_health
        const filled = math.round(ratio * 20)
        const bar = colors.rgb(vector.lerp(EMPTY, FULL, ratio), string.rep("█", filled))
            .. colors.style.dim(string.rep("░", 20 - filled))
        return `{bar} {self.current}/{self.max_health}`
    end
end

local hp = Health.full(100)
print(hp)
hp -= 65
print(hp)
hp += 500
print(hp, hp == Health.full(100))
hp -= 90
print(hp, hp < Health.full(20))

The healthbar's colors come from seal's @std/io/colors library. Save the example as health.luwu and run it with seal:

seal ./health.luwu
████████████████████ 100/100
███████░░░░░░░░░░░░░ 35/100
████████████████████ 100/100	true
██░░░░░░░░░░░░░░░░░░ 10/100	true

Overhealing by 500 stops at 100/100 because every result passes through math.clamp. From outside the class, hp.current = -20 is a type error and a runtime error, and Health(-5, 100) fails because the constructor is private.

Operators on objects

Metamethods let your objects work with operators like + and ==. Name a method after the metamethod.

A floating point number can't hold most fractions exactly (0.1 + 0.2 is 0.30000000000000004). Money keeps whole units and cents in separate fields instead. Its constructor and fields are private, and every amount comes from Money.usd, Money.eur or Money.gbp. Those reject negative amounts and fractions of a cent before an object ever exists.

type Currency = "USD" | "EUR" | "GBP"

class Money private (
    private const units: number,
    private const cents: number,
    public const currency: Currency
)
    public function usd(units: number, cents: number): Money
        return Money.of(units, cents, "USD")
    end
    public function eur(units: number, cents: number): Money
        return Money.of(units, cents, "EUR")
    end
    public function gbp(units: number, cents: number): Money
        return Money.of(units, cents, "GBP")
    end
    private function of(units: number, cents: number, currency: Currency): Money
        if
            units % 1 ~= 0
            or cents % 1 ~= 0
            or units < 0
            or cents < 0
            or cents > 99
        then
            error("expected whole units and 0 to 99 cents", 3)
        end
        return Money(units, cents, currency)
    end
    private function check_currency(self, other: Money)
        if self.currency ~= other.currency then
            error(`can't mix {self.currency} and {other.currency}`, 3)
        end
    end
    public function __add(self, other: Money): Money
        self:check_currency(other)
        const cents = self.cents + other.cents
        const units = self.units + other.units + cents // 100
        return Money(units, cents % 100, self.currency)
    end
    public function __eq(self, other: Money): boolean
        return self.units == other.units
            and self.cents == other.cents
            and self.currency == other.currency
    end
    public function __lt(self, other: Money): boolean
        self:check_currency(other)
        if self.units ~= other.units then
            return self.units < other.units
        end
        return self.cents < other.cents
    end
    public function __tostring(self): string
        return string.format("%d.%02d %s", self.units, self.cents, self.currency)
    end
end

const total = Money.usd(19, 99) + Money.usd(5, 1)
print(`{total}`)
print(total == Money.usd(25, 0), total < Money.usd(30, 0))

Checking what an object is

class.isinstance tells you whether a value is an object of a class.

Inside the check, Luwu knows the value's type.

class Circle(radius: number) end
class Square(side: number) end

const function area(shape: Circle | Square): number
    if class.isinstance(shape, Circle) then
        return math.pi * shape.radius ^ 2
    end
    return shape.side ^ 2
end

print(area(Circle(1)), area(Square(3)))
print(class.name(Square(3)))

Traits

Traits are how classes inherit behavior in Luwu. A trait lists what it expects from a class and what it gives the class in return. A class can implement as many traits as it needs.

Sharing behavior

Here, Lootable gives any class a working drop method. It only asks the class for a loot array.

trait Lootable
    expect loot: { string }

    function drop(self): { string }
        const dropped = self.loot
        self.loot = {}
        return dropped
    end
end

class Chest(loot: { string }) implements Lootable end
class Slime(hp: number, loot: { string }) implements Lootable end

const chest = Chest({ "gold", "map" })
const slime = Slime(5, { "goo" })
print(#chest:drop(), #chest:drop(), slime:drop()[1])

Both classes get the trait's methods without writing them again.

Expected members

expect lists what an implementing class must provide: fields, functions or both.

Leaving one out is a type error and an error when the class is created.

trait Describable
    expect const name: string
    expect function details(self): string

    function describe(self): string
        return `{self.name}: {self:details()}`
    end
end

class Sword implements Describable
    const name = "Iron sword"
    damage = 7

    function details(self): string
        return `deals {self.damage} damage`
    end
end

print(Sword():describe())

Overriding a trait's function

A class can replace a trait's function and still reach the trait's version with Trait.function(self).

trait Greeting
    function greet(self): string
        return "Hello!"
    end
end

class Pirate implements Greeting
    function greet(self): string
        return Greeting.greet(self) .. " Arr!"
    end
end

print(Pirate():greet())

The override must take the same arguments the trait's version does. Extra arguments must be optional.

Traits that need other traits

A trait can needs another trait. Implementing it implements both.

trait Named
    expect const name: string
end

trait Speaker needs Named
    function introduce(self): string
        return `I'm {self.name}.`
    end
end

class Robot implements Speaker
    const name = "R2"
end

print(Robot():introduce())

Final members

Mark a trait's member final when classes must not replace it.

A final function without self is a static function on the trait. It's a good place for helpers that belong with the trait.

trait Shape
    expect function area(self): number

    final function total_area(shapes: { Shape }): number
        local total = 0
        for _, shape in shapes do
            total += shape:area()
        end
        return total
    end
end

class Rectangle(width: number, height: number) implements Shape
    function area(self): number
        return self.width * self.height
    end
end

class Circle(radius: number) implements Shape
    function area(self): number
        return math.pi * self.radius ^ 2
    end
end

print(Shape.total_area({ Rectangle(2, 3), Circle(1) }))

Traits with parameters

A trait can take parameters, like a class's constructor. The class passes them when it implements the trait.

trait Stackable(max_stack: number = 64)
    count = 1

    function add(self, amount: number): number
        const room = self.max_stack - self.count
        const added = math.min(room, amount)
        self.count += added
        return amount - added
    end
end

class Arrow implements Stackable end
class Potion implements Stackable(8) end

print(Arrow():add(100), Potion():add(100))

Working with any implementer

A trait is also a type. { Shape } is an array of anything that implements Shape.

Trait is any object whose class implements Trait, class<Trait> is the type of the class value of any class that implements Trait, and trait<Trait> is the type of the trait value itself. Here's an example of all three:

trait Animal -- Animal is trait<Animal>
    expect species: string
end

class Cat( -- Cat is class<Animal>
    name: string,
    age: number
) implements Animal
    species = "Felis catus"
end

const nyla = Cat("Nyla", 14) -- nyla is Animal

class.isinstance asks whether a value is an object of one exact class, while class.implements asks whether the value's class implements a trait. Inside either check Luwu narrows the value's type. Once describe knows it has an Animal, reading thing.species type checks:

trait Animal
    expect species: string
end

class Cat(name: string) implements Animal
    species = "Felis catus"
end

class Dog(name: string) implements Animal
    species = "Canis familiaris"
end

class Rock() end

const function describe(thing: unknown): string
    if class.implements(thing, Animal) then
        return `an animal of species {thing.species}`
    elseif class.isinstance(thing, Rock) then
        return "a rock"
    end
    return "something else"
end

print(describe(Cat("Nyla")))
print(describe(Dog("Rex")))
print(describe(Rock()))

Written with only class.isinstance, describe would need a check for Cat, another for Dog and one more for every animal class anyone adds later. A single class.implements check covers them all. class.isinstance only takes classes, and when it's given a trait luwu-analyze reports Expected this to be a class, got trait 'Animal'.

Traits as factories

Sometimes code has to build an object without knowing its class. A parser that reads HTML back into objects only knows the tags it found, and apps add components the parser has never heard of. A trait can build those objects. Give the trait a __create function and calling the trait like a class runs it to pick a concrete class and return a new object of that class.

This Component trait holds a set of class<Component> values. Each class is keyed by its identity, and two classes that happen to share a name stay separate entries. Its __implemented function runs once when each class that implements the trait is defined and adds that class to the set. Component("button") then builds a Button:

const components: { [class<Component>]: true? } = {}

trait Component
    expect function __init(self)
    expect function tag(): string
    expect function render(self): string
    function __create(tag: string): Component
        for cls in components do
            if cls.tag() == tag then
                return cls()
            end
        end
        return error(`no component renders <{tag}>`, 2)
    end
    final function __implemented(cls: class<Component>)
        components[cls] = true
    end
end

class Button() implements Component
    function tag(): string
        return "button"
    end
    function render(self): string
        return "<button>Save</button>"
    end
end

class Spinner() implements Component
    function tag(): string
        return "progress"
    end
    function render(self): string
        return "<progress></progress>"
    end
end

-- tags a parser found in the page
for _, tag in { "progress", "button" } do
    print(Component(tag):render())
end

__implemented is always final and takes the class rather than self. It also runs for classes that implement the trait through another trait's needs. It runs once per class even when several traits lead to it, and the hooks of the traits a trait needs run first. Traits hold no state. The registry is a table in the module beside it.

Only traits that define __create can be called. Calling the Animal trait from earlier raises trait 'Animal' can't be called: it has no '__create'.

Things to know