Skip to main content

Classes & OOP

Nebra provides TypeScript-like classes that compile to idiomatic Lua metatable patterns.

Basic Class

class Animal
name: string
age: number = 0

constructor(name: string, age: number)
self.name = name
self.age = age
end

function speak(): string
return self.name .. " makes a sound"
end

static function create(name: string): Animal
return new Animal(name, 0)
end
end

Instantiation

local a = new Animal("Rex", 5)
a:speak() -- instance method (colon syntax)
Animal.create("Buddy") -- static method (dot syntax)

new Animal(args) compiles to Animal.new(args).

Always use new for construction. Nebra requires new ClassName(args) to create an instance - even when the underlying runtime expects a different call shape (e.g. ClassName(args) without a method qualifier). The @overrideCtor builtin rewrites the codegen to match the runtime's convention, but the source must still write new. A bare ClassName(args) is parsed as a function call and rejected by the type checker (expected 'function', but got 'class<…>'). This makes the constructor call site visually distinct from every other function call and keeps the parser unambiguous.

Lua Output

local Animal = {}
Animal.__index = Animal

function Animal.new(name, age)
local self = setmetatable({}, Animal)
self.name = name
self.age = age
return self
end

function Animal:speak()
return self.name .. " makes a sound"
end

function Animal.create(name)
return Animal.new(name, 0)
end

Fields

class Config
-- Instance field (on every instance)
timeout: number = 30

-- Static field (on the class table itself)
static version: string = "1.0"

-- Local field (file-scoped, not on the class table)
local internalCounter: number = 0

-- Protected field (on the class table, accessible in subclasses)
protected debugMode: boolean = false
end

Methods

class Service
-- Instance method (uses colon syntax)
function process(data: string): string
return data
end

-- Static method (uses dot syntax)
static function initialize(): void
print("init")
end

-- Async method
async function fetchData(url: string): string
return await httpGet(url)
end

-- Local method (file-scoped helper, not on class)
local function helper(): void
print("internal")
end
end

Instance methods carry an implicit self. Nebra models every non-static class method as if its first parameter were self: ThisClass, even though you don't write it. That mirrors Lua's runtime: obj:method(args) desugars to obj.method(obj, args), and Nebra's codegen emits exactly that. Two practical consequences:

  • Instance methods must be called with obj:method(args) (colon). Writing obj.method(args) is rejected with ErrInstanceMethodNeedsColon because . would leave self as nil at runtime.
  • Static methods are stored separately and dispatched via Class.method(args) (dot). Calling them with a colon is a type error.

Interface methods are different - see the alert in the Interfaces doc.

Inheritance

class Dog extends Animal
breed: string

constructor(name: string, age: number, breed: string)
super(name, age) -- must call super in derived constructor
self.breed = breed
end

override function speak(): string
return "Woof!"
end
end

Lua Output (Inheritance)

local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog

function Dog.new(name, age, breed)
local self = Animal.new(name, age)
setmetatable(self, Dog)
self.breed = breed
return self
end

function Dog:speak()
return "Woof!"
end

Super Calls

super(args) can only be used inside a derived class constructor. It must be the first statement.

class Cat extends Animal
constructor(name: string)
super(name, 0)
end
end

Override

Mark methods that override a parent method with override:

class Circle extends Shape
override function area(): number
return 3.14 * self.radius ^ 2
end
end

If you define a method that exists in a parent without override, the compiler emits a warning about shadowing. If you use override but no matching parent method exists, it's an error.

Abstract Classes

Abstract classes cannot be instantiated directly. They may contain abstract methods (signature only, no body):

abstract class Shape
abstract function area(): number
abstract function perimeter(): number

function describe(): string
return `Area: {self:area()}, Perimeter: {self:perimeter()}`
end
end

-- ERROR: Cannot instantiate abstract class
-- local s = new Shape()

class Rectangle extends Shape
width: number
height: number

constructor(w: number, h: number)
self.width = w
self.height = h
end

override function area(): number
return self.width * self.height
end

override function perimeter(): number
return 2 * (self.width + self.height)
end
end

local r = new Rectangle(10, 5) -- OK

Abstract methods compile to error stubs:

function Shape:area()
error("Abstract method 'area' must be implemented")
end

Non-abstract subclasses must implement all abstract methods, or the compiler reports an error.

Protected Members

Protected fields and methods are accessible within the class and its subclasses:

class Base
protected secret: string = "hidden"

protected function validate(): boolean
return true
end
end

class Child extends Base
function check(): boolean
return self:validate() -- OK: accessing protected from subclass
end
end

Protected members are compiled as regular members on the class table (no runtime enforcement, compile-time checks only).

Getters & Setters

class Person
_age: number = 0

get age(): number
return self._age
end

set age(value: number)
if value >= 0 then
self._age = value
end
end
end

local p = new Person()
p.age = 25 -- calls setter
print(p.age) -- calls getter

Lua Output (Accessors)

When a class has getters/setters, Nebra uses a proxy metatable:

local function __nebra_class_proxy(cls, parent)
return {
__index = function(t, k)
local g = cls["__get_" .. k]
if g then return g(t) end
local v = cls[k]
if v ~= nil then return v end
if parent then
g = parent["__get_" .. k]
if g then return g(t) end
return parent[k]
end
end,
__newindex = function(t, k, v)
local s = cls["__set_" .. k]
if s then s(t, v) return end
if cls["__get_" .. k] then
error("Cannot set readonly property '" .. k .. "'")
end
rawset(t, k, v)
end
}
end

Read-only properties (getter without setter) throw an error on write.

Implementing Interfaces

class Sprite implements Drawable, Serializable
override function draw(): void
print("drawing")
end

override function serialize(): string
return "{}"
end
end

See Interfaces.

Operator Overloading

Classes can define how built-in operators behave on their instances using the operator keyword. Each overload compiles to a Lua metamethod on the class table, so operator dispatch happens through Lua's normal metatable mechanism.

class Vector2
x: number
y: number

constructor(x: number, y: number)
self.x = x
self.y = y
end

operator +(other: Vector2): Vector2
return new Vector2(self.x + other.x, self.y + other.y)
end

operator -(other: Vector2): Vector2
return new Vector2(self.x - other.x, self.y - other.y)
end

operator -(): Vector2
return new Vector2(-self.x, -self.y)
end

operator ==(other: Vector2): boolean
return self.x == other.x and self.y == other.y
end
end

local a = new Vector2(1, 2)
local b = new Vector2(3, 4)
local sum = a + b -- calls Vector2:__add
local neg = -a -- calls Vector2:__unm
local eq = a == b -- calls Vector2:__eq

Supported Operators

OperatorArityMetamethod
+binary__add
-binary__sub
-unary__unm
*binary__mul
/binary__div
//binary__idiv
%binary__mod
^binary__pow
..binary__concat
==binary__eq
<binary__lt
<=binary__le
#unary__len

Unary vs. binary - is disambiguated by parameter count: zero parameters means unary negation (__unm); one parameter means subtraction (__sub).

Inside an operator body, self refers to the left-hand operand (or the sole operand for unary overloads). Binary overloads receive the right-hand operand as the single parameter.

Inheritance

Operator overloads are inherited: a subclass automatically picks up its base class's operators unless it defines its own. Re-declaring an operator in a subclass overrides the inherited one.

Exported Classes

export class User
name: string
email: string

constructor(name: string, email: string)
self.name = name
self.email = email
end
end

Extension Methods

extend Type ... end adds methods to an existing type - a class, an interface, or a built-in like string, number, function or thread - without touching its original definition. Inside an extension method self is the receiver.

extend number
function double(): number
return self * 2
end
function clamp(lo: number, hi: number): number
if self < lo then return lo end
if self > hi then return hi end
return self
end
end

extend string
function shout(): string
return string.upper(self) .. "!"
end
end

print((21):double()) -- 42
print((5):clamp(0, 3)) -- 3
print(("hi"):shout()) -- HI!

A call like receiver:method(args) lowers at compile time to a plain function call, so extension methods work on every type - including number and boolean - with zero runtime overhead and no metatable tricks. An extension on a base class or an interface is visible on all its subclasses / implementors. A real method always wins over an extension of the same name.

Notes: extension functions are currently file-local (cross-file use is a work in progress), and self on a primitive extension cannot call built-in methods via : (use string.upper(self)).