Classes, Interfaces, And Enums
Classes
class User {
string name;
func User(string name) {
this.name = name;
}
func displayName(): string {
return this.name;
}
}
Constructors use the class name. Instance methods use this.
Fields should be declared on the class so instances have a predictable shape:
class User {
string id;
string email;
func User(string id, string email) {
this.id = id;
this.email = email;
}
}
Keep constructors focused on valid initialization. Use named static factories or module functions when construction needs parsing, I/O, or fallback behavior.
Method Overloading
A class may define multiple methods (or constructors) with the same name, as long as the signatures differ by the number or types of parameters. The runtime selects the best-matching overload at call time:
import io;
class Printer {
func print(string s): void {
io.println("string: " + s);
}
func print(int n): void {
io.println("int: " + n);
}
func print(string label, int n): void {
io.println(label + ": " + n);
}
}
let p = Printer();
p.print("hello"); # string: hello
p.print(42); # int: 42
p.print("count", 7); # count: 7
Constructor overloading follows the same rules:
class Point {
decimal x;
decimal y;
func Point() {
this.x = 0;
this.y = 0;
}
func Point(decimal x, decimal y) {
this.x = x;
this.y = y;
}
}
let origin = Point();
let p = Point(3.0, 4.0);
When a call matches no overload, or multiple overloads match equally well, a runtime type error is raised identifying the method name and the arguments that were passed.
Methods and constructors may also differ by return type when the surrounding context provides an expected type. For example, assigning the result to a typed variable can select the overload. Without that expected type, a call that matches only by return type is ambiguous and raises a runtime type error.
Inheritance
Classes use extends to inherit from one parent class:
class Admin extends User {
func Admin(string name) {
parent(name);
}
func displayName(): string {
return "admin " + parent.displayName();
}
}
Geblang classes currently support single class inheritance: one class can extend one parent class. Multiple class inheritance is not part of the language today. Use interfaces for multiple behavioral contracts and composition for sharing services or collaborators.
Child classes inherit parent fields and methods. If the parent has a
zero-argument constructor and the child constructor does not explicitly call
parent(...), Geblang calls the parent constructor automatically. If the parent
constructor requires arguments, call parent(...) yourself:
class Animal {
string name;
func Animal(string name) {
this.name = name;
}
func speak(): string {
return this.name + " makes a sound";
}
}
class Dog extends Animal {
func Dog(string name) {
parent(name);
}
func speak(): string {
return parent.speak() + ", then barks";
}
}
parent.method() calls the parent implementation from an override. parent(...)
calls the parent constructor. An explicit parent(...) call suppresses the
automatic no-argument parent constructor call, so the parent constructor only
runs once.
Automatic parent constructor example:
class Base {
int count = 0;
func Base() {
this.count = this.count + 1;
}
}
class Child extends Base {
func Child() {
# Base() is called automatically before this body runs.
}
}
io.println(Child().count); # 1
Use inheritance for true specialization. Prefer composition when one object merely needs to use another service:
class UserService {
UserRepository repo;
func UserService(UserRepository repo) {
this.repo = repo;
}
}
Static Members
Classes can declare both immutable constants and mutable fields at
class scope, plus static methods. static const makes an immutable
class-level binding; static let and the typed form static <type>
declare a mutable class-level field that any code in scope can read
and reassign.
class Build {
static const VERSION = "0.1.0";
static func label(): string {
return Build.VERSION;
}
}
class Counter {
static let count = 0; # untyped, mutable
static int errors = 0; # typed, mutable
static func tick(): int {
Counter.count = Counter.count + 1;
return Counter.count;
}
}
Counter.tick();
Counter.tick();
io.println(Counter.count); # 2
Counter.errors = 5; # external assignment also works
io.println(Counter.errors); # 5
Reading static members from inside the class uses the same ClassName.member
syntax; there is no implicit self for static methods.
Immutable Classes
Apply the @immutable decorator to freeze every instance after its constructor
returns. Fields are readable; any assignment to a field raises ImmutableError.
@immutable class Point {
int x;
int y;
func Point(int x, int y) { this.x = x; this.y = y; }
}
Point p = Point(3, 4);
io.println(p.x); # 3
p.x = 99; # throws ImmutableError
Produce modified copies using the wither pattern instead of mutation:
@immutable class Point {
int x;
int y;
func Point(int x, int y) { this.x = x; this.y = y; }
func withX(int x): Point { return Point(x, this.y); }
}
See the freeze module documentation for freeze.shallow, freeze.deep,
freeze.isFrozen, .copy(), and const collection auto-freeze behavior.
Interfaces
interface Printable {
func print(): string;
}
class Report implements Printable {
func print(): string {
return "report";
}
}
Interfaces can inherit from other interfaces. Classes explicitly declare
implements.
Interfaces may extend multiple interfaces:
interface Printable {
func print(): string;
}
interface Serializable {
func serialize(): string;
}
interface Reportable extends Printable, Serializable {
func title(): string;
}
Classes can implement multiple interfaces:
class Report implements Printable, Serializable {
func print(): string {
return "report";
}
func serialize(): string {
return "{\"type\":\"report\"}";
}
}
This is the main way to model "multiple inheritance" style contracts in Geblang: one concrete parent class for implementation inheritance, many interfaces for capabilities.
Interfaces are structural contracts for application boundaries. They work well for repositories, renderers, cache stores, middleware, and test doubles.
interface CacheStore {
func get(string key): any;
func set(string key, any value, int ttl): void;
func delete(string key): void;
}
Interfaces work well with dependency injection:
class CachedUsers {
CacheStore cache;
func CachedUsers(CacheStore cache) {
this.cache = cache;
}
}
Magic Methods
Magic methods are ordinary methods with reserved names. They let a class opt into dynamic property access, callable object behavior, and operator overloading. Keep them focused: a class should only implement the magic methods that match its public role.
Dynamic Access And Method Dispatch
Use __get, __set, and __call for dynamic objects such as records, proxies,
configuration wrappers, or framework adapters.
class Bag {
dict<string, any> values;
func Bag() {
this.values = {};
}
func __get(string name): any {
if (this.values.hasKey(name)) {
return this.values[name];
}
return null;
}
func __set(string name, any value): void {
this.values[name] = value;
}
func __call(string name, list<any> args): any {
return {"method": name, "args": args};
}
}
Dynamic access is useful at framework boundaries, but normal declared fields and methods should be preferred for domain code because they are easier to type check and document.
Callable Objects
Implement __invoke when an object should be usable like a function. This is
useful for middleware, guards, predicates, command handlers, and strategy
objects that need constructor state.
class Prefixer {
string prefix;
func Prefixer(string prefix) {
this.prefix = prefix;
}
func __invoke(string value): string {
return this.prefix + value;
}
}
let shout = Prefixer("hello ");
io.println(shout("Ada"));
Callable objects can be passed to parameters typed as callable.
Operator Overloading
Operator methods customize how instances interact with operators:
- equality:
__eq(other) - ordering:
__lt(other),__lte(other),__gt(other),__gte(other) - arithmetic:
__add,__sub,__mul,__div,__intdiv,__mod,__pow - bitwise:
__bitand,__bitor,__bitxor,__lshift,__rshift - prefix:
__not,__neg,__bitnot
Example:
class Money {
int cents;
func Money(int cents) {
this.cents = cents;
}
func __add(Money other): Money {
return Money(this.cents + other.cents);
}
func __eq(Money other): bool {
return this.cents == other.cents;
}
func __lt(Money other): bool {
return this.cents < other.cents;
}
}
let total = Money(500) + Money(250);
io.println(total == Money(750));
Operator methods should return the type users expect from the operator.
Comparison and equality methods must return bool; arithmetic methods should
usually return the same domain type.
Destructors
A class can declare a destructor with the func ~ClassName() syntax. The
destructor takes no arguments and is called when an instance reaches the end
of its lifetime - either at program exit (the runtime sweeps every
destructor-bearing instance that hasn't already been destroyed) or via an
explicit del x; statement. Destructors are end-of-lifetime hooks; they are
not tied to with-blocks, which serve a separate purpose (see Context
Managers below).
import io;
class FileHandle {
string path;
int fd;
func FileHandle(string path) {
this.path = path;
this.fd = io.open(path, "r");
}
func ~FileHandle() {
io.close(this.fd);
io.println("closed " + this.path);
}
}
let f = FileHandle("data.txt");
/* ... use f ... */
/* At program exit (or when `del f;` runs), ~FileHandle fires. */
At the program-exit sweep, destructors fire in reverse-creation order (LIFO) so younger instances - which may depend on older ones - clean up first. Destructor exceptions are logged to stderr but never abort the sweep; every remaining instance still gets a chance to run.
Explicit destruction with del
Use del x; to retire a binding mid-script. The runtime invokes the
destructor (if the class declares one) immediately and removes the binding
from the surrounding scope:
let f = FileHandle("data.txt");
useFile(f);
del f; /* ~FileHandle fires here. */
io.println("file already closed");
del only accepts an identifier - del a.b; and del a[0]; are parse
errors. After del x, the static analyzer rejects subsequent references to
x in the same control-flow path with use of destroyed binding "x". A
fresh let x = ...; re-introduces the name with a new lifetime.
Destructors that throw during a sweep print the error to stderr but do not crash the sweep.
Context Managers (with, __enter__, __exit__)
The with statement runs the magic methods __enter__() and __exit__()
on the bound resource. It is a scoped-cleanup construct, distinct from the
destructor lifecycle.
class Transaction {
string label;
func Transaction(string label) { this.label = label; }
func __enter__(): Transaction {
io.println("begin " + this.label);
return this;
}
func __exit__(): void {
io.println("commit " + this.label);
}
}
with (tx = Transaction("write")) {
io.println("inside " + tx.label);
}
/* Output:
* begin write
* inside write
* commit write
*/
Two forms are accepted: with (expr) { ... } discards the value;
with (name = expr) { ... } binds the result of __enter__() (or the
expression itself when __enter__() is undefined) to name. At block exit
- normal completion, exception,
return,break, orcontinue- the runtime invokes__exit__()if defined; otherwise the block exits as a no-op. The class destructor is not called at this point; it fires later via the lifetime mechanism described above.
If you want both - per-block cleanup AND end-of-lifetime cleanup - declare both methods.
Serialisation: __serialize__ And __deserialize__
Class instances serialise out of the box. json.stringify, yaml.stringify,
and toml.stringify accept an instance and dump its public fields:
- Fields whose name does not start with
_are emitted. - Fields beginning with
_or__are treated as private and skipped.
No opt-in is needed for plain data classes.
import json;
class Point {
int x;
int y;
int _secret;
func Point(int x, int y) { this.x = x; this.y = y; this._secret = 99; }
}
io.println(json.stringify(Point(3, 4)));
/* {"x":3,"y":4} - _secret is omitted. */
A class can replace the default by implementing __serialize__(). The return
value is itself serialised by the stringify call, so any dict/list/scalar shape
works:
class Tagged {
string kind;
string label;
func Tagged(string kind, string label) {
this.kind = kind; this.label = label;
}
func __serialize__(): dict {
return {"kind": this.kind, "label": this.label, "v": 1};
}
}
The symmetric parseAs(text, ClassRef) reconstructs an instance:
let p = json.parseAs("{\"x\": 3, \"y\": 4}", Point);
io.println(p.x);
io.println(p.y);
parseAs first looks for a static __deserialize__(dict) factory on the
target class. When present it is called with the parsed value:
class Tagged {
string kind;
string label;
func Tagged(string kind, string label) {
this.kind = kind; this.label = label;
}
static func __deserialize__(dict d): Tagged {
return Tagged(d["kind"], d["label"]);
}
}
When no __deserialize__ exists, parseAs matches the dict keys to the
constructor's parameter names and calls the constructor positionally. A
missing required parameter raises a runtime error.
The same machinery applies to yaml.parseAs, toml.parseAs, and
xml.parseAs.
Enums
enum Color { Red, Green, Blue }
enum Result { Ok(string), Err(string) }
let color = Color.Red;
let result = Result.Ok("saved");
Enums support equality, instanceof, and match destructuring.
Enums are a good fit for closed sets and tagged results:
enum SaveResult {
Saved(string id),
Duplicate(string field),
Failed(string message)
}
let message = match (result) {
case SaveResult.Saved(string id) => "saved " + id;
case SaveResult.Duplicate(string field) => "duplicate " + field;
case SaveResult.Failed(string error) => error;
};