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
Cross-module inheritance
A class can extend a parent defined in another module. Two forms are equivalent:
# shapes.gb
module shapes;
export class Shape {
string color;
func Shape(string color) { this.color = color; }
func area(): float { return 0.0f; }
}
Qualified import - use the module prefix in the extends clause:
import shapes;
class Circle extends shapes.Shape {
float radius;
func Circle(float radius) { parent("red"); this.radius = radius; }
func area(): float { return 3.14159f * this.radius * this.radius; }
}
From-import - bring the name into scope first:
from shapes import Shape;
class Square extends Shape {
float side;
func Square(float side) { parent("blue"); this.side = side; }
func area(): float { return this.side * this.side; }
}
Both forms give the subclass full access to inherited fields and methods.
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.
Set-once fields
For finer control, annotate an individual field with @immutable instead of
the whole class. A set-once field is freely writable while the constructor runs
and locked once construction completes; a later assignment raises
ImmutableError. Other fields stay mutable.
class User {
@immutable string id; # set once, then locked
string name; # mutable
func User(string id, string name) {
this.id = id; # ok
this.name = name;
}
}
let u = User("u1", "Ada");
u.name = "Ada L."; # ok
u.id = "u2"; # throws ImmutableError
A set-once field is locked at the end of construction, so the constructor may
assign it more than once if it needs to; the lock applies afterwards. An
@immutable field inherited from a parent class is locked too. An @immutable
field may not declare a default value (@immutable int x = 5; is a compile
error) - the constructor must set it.
Data Classes
The @dataclass decorator generates the boilerplate that a value-style class
usually needs, from its declared fields:
- a constructor taking the fields in declaration order (generated only when the class declares no constructor of its own),
- value-based equality (
__eq) comparing every field, - a readable
__stringrendering (Point(x=1, y=2)), - a
with(...)copy helper that returns a new instance with named fields replaced.
@dataclass
class Point {
int x;
int y;
}
let p = Point(1, 2);
io.println(p); # Point(x=1, y=2)
io.println(p == Point(1, 2)); # true
let q = p.with({"y": 9}); # Point(x=1, y=9), p is unchanged
A field with a default becomes an optional constructor parameter:
@dataclass
class Tag {
string name;
int weight = 1;
}
Tag("a"); # weight defaults to 1
Tag("b", 3);
Any member you write yourself takes precedence over the generated one, so you
can override __string, __eq, the constructor, or with as needed.
@dataclass(frozen: true) additionally makes instances immutable (the same
whole-instance freeze as @immutable), so a frozen data class is a true value
object:
@dataclass(frozen: true)
class Money {
int cents;
}
let m = Money(500);
# m.cents = 0; # throws ImmutableError
let n = m.with({"cents": 250}); # build a changed copy instead
A frozen instance is also usable as a dict key or set member by value: two frozen instances with equal fields are the same key, so sets deduplicate them and dict lookups find them regardless of which instance you pass.
let prices = {Money(100), Money(100), Money(200)};
io.println(prices.length()); # 2
io.println(prices.contains(Money(100))); # true
Only frozen instances key by value; a mutable instance keys by identity (so it
cannot change out from under a dict). @dataclass operates on the class's own
declared fields and is intended for flat value classes; a data class that
extends another class must declare its own constructor.
@override
Annotate a method with @override to assert that it overrides a method of the
same name declared on an ancestor class or an implemented interface. If neither
declares it, it is a compile-time error - which catches a parent or interface
method that was renamed or removed out from under the override.
class Animal {
func speak(): string { return "..."; }
}
class Dog extends Animal {
@override
func speak(): string { return "woof"; }
}
The check is by method name. When the parent class is not visible (for example, imported from another module the analyzer cannot resolve), the assertion is skipped rather than reported, so it never false-positives.
@deprecated
Mark a function, method, or class @deprecated to flag it for removal. Every use
site is reported by geblang check as a warning[deprecated]; an optional
message points callers at the replacement. It is advisory only and never changes
whether code runs.
@deprecated("use fetchUser instead")
func getUser(int id): User { return fetchUser(id); }
# geblang check: warning[deprecated]: use of deprecated getUser: use fetchUser instead
let u = getUser(1);
Other built-in decorators
@memoize caches a function's result by its arguments. It applies to top-level
functions only (not methods), so it is documented with the other function
features in Functions And Callables.
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;
}
}
Default method implementations
An interface method with a body is a default implementation. Classes that implement the interface and don't override the method inherit the body as-is. Classes that override get their own.
interface Greetable {
string name;
func greet(): string {
return "hello, " + this.name;
}
func loudName(): string;
}
class User implements Greetable {
func User(string n) { this.name = n; }
func loudName(): string { return this.name.upper(); }
/* greet inherited from Greetable */
}
class Loud implements Greetable {
func Loud(string n) { this.name = n; }
func greet(): string { return "HELLO, " + this.name; }
func loudName(): string { return this.name.upper(); }
}
io.println(User("ada").greet()); /* "hello, ada" */
io.println(Loud("ada").greet()); /* "HELLO, ada" */
Interface properties
An interface can declare property requirements as bare field declarations. Every implementing class automatically gains those fields - no need to redeclare them in the class body.
interface Greetable {
string name; /* every implementer has `name` */
int age; /* and `age` */
func greet(): string { return this.name + " is " + (this.age as string); }
}
class User implements Greetable {
func User(string n, int a) {
this.name = n; /* set the inherited field */
this.age = a;
}
}
When a class implements multiple interfaces that declare the same field name, the runtime keeps one field as long as the declared types match. Conflicting types are a compile-time error.
Multi-interface defaults: the diamond
When class C implements A, B and both A and B provide a
default body for the same method signature, C must override the
method explicitly. Inheriting one of the two defaults silently
would be ambiguous, so the compiler rejects the class:
interface A { func foo(): string { return "A"; } }
interface B { func foo(): string { return "B"; } }
class C implements A, B {} /* error: ambiguous default for foo() */
class C implements A, B {
func foo(): string { return "C"; } /* OK */
}
The rule only fires for conflicting defaults. If only one of
the interfaces provides a default and the other declares the same
signature without a body, C inherits the default unambiguously.
Decorators
Decorators are @-prefixed annotations applied to classes, functions,
methods, or fields. They come in two flavours depending on where you
put them, and the two flavours are doing very different things. This
section explains both.
Behavioural decorators on functions, methods, and classes
A decorator applied to a func or class declaration whose name
resolves to a function in scope wraps the target. The target value
is passed to the decorator at definition time, and the decorator's
return value replaces it. Use this to add cross-cutting behaviour like
logging, caching, retry, or timing.
func log(callable fn): callable {
return func(any ...args): any {
io.println("calling", reflect.className(fn));
return fn(...args);
};
}
@log
func greet(string name): string {
return "hi, " + name;
}
Calling greet("Ada") invokes the wrapped value: the log line prints
first, then the underlying body runs. Multiple decorators stack in
source order. The topmost decorator wraps the inner wrapped value
last.
The same rule applies to class decorators. A class decorator receives the class value at definition time and decides what to return. Two useful shapes:
/* Register-in-place. Side effect, return the class unchanged. */
dict<string, any> services = {};
func service(any cls): any {
services[reflect.className(cls)] = cls;
return cls;
}
@service
class Auth { ... }
/* Wrap. Return a callable that becomes the new constructor. */
func audited(any cls): any {
return func(...args): any {
io.println("constructing", reflect.className(cls));
return cls(...args);
};
}
@audited
class Order { ... }
Inside a wrap closure, calling the captured cls(args) constructs
the original class without re-triggering the decorator chain, so
the body can pre/post-process around construction without
recursing.
Typed delegation. A wrap closure may return an instance of a
different class than the decorated one. The runtime stamps the
returned instance so it still satisfies instanceof against the
original class name:
class JsonRepository {
func JsonRepository(string conn) { ... }
func find(string id): User { ... }
}
func storage(any cls): any {
return func(string conn): any { return JsonRepository(conn); };
}
@storage
class UserRepository {
func UserRepository(string conn) {}
}
let ur = UserRepository("postgres://...");
io.println(ur instanceof UserRepository); /* true */
io.println(ur instanceof JsonRepository); /* true */
ur.find("ada"); /* JsonRepository's find runs */
The instance is structurally a JsonRepository (its methods + fields) but typed as both. Useful when one declared type fronts an implementation that gets chosen by a decorator at definition time (swap-by-config, ORM proxies, test stubs).
Annotation-only (metadata) decorators
A decorator whose name does not resolve to a function in scope is treated as pure metadata. The runtime does not execute it. The name and any arguments are recorded on the target so reflection can read them back. This is the form frameworks use to drive configuration-by-annotation:
@Get("/users/{id}")
@Summary("Fetch one user")
func getUser(string id): User { ... }
@Get and @Summary are not functions; nothing happens at definition
time. A web framework reads them later via
reflect.decorators(getUser).
Dotted names like @Assert.email or @Foo.bar.baz are valid and
parse as a single composite identifier. The dot is part of the name,
so dispatch is by exact string match. Use this to group related
annotations under a common prefix.
Field-level decorators
A field decorator whose name resolves to a function in scope is a write barrier: it runs on every assignment to that field (including the constructor's first write), transforms the incoming value, and the transformed value is what gets stored. Use them for normalisation, validation, formatting, audit.
func upper(string v): string { return v.upper(); }
func minLen(int min, string v): string {
if (v.length() < min) { throw RuntimeError("too short"); }
return v;
}
class User {
@minLen(2)
@upper
string name;
func User(string n) { this.name = n; }
}
let u = User("ada");
io.println(u.name); /* "ADA" - @upper ran on the constructor write */
u.name = "x"; /* throws RuntimeError("too short") */
The decorator's last parameter is the value being assigned. Any
earlier parameters come from the decorator's literal args
(@minLen(2) passes 2 to min). Decorators stack bottom-up:
the one closest to the field declaration runs first, and each
transform's output feeds the next.
A field decorator whose name does not resolve to a function is treated as pure metadata: the runtime never executes it, but frameworks read it back via reflection to drive configuration-by-annotation. This is the form used by libraries like Gebweb for validation rules, serialisation filters, OpenAPI hints, and similar concerns:
class CreateUserDTO {
@Assert.email
string email;
@Assert.minLength(2)
@Assert.maxLength(64)
string name;
}
Here @Assert.email doesn't resolve to a function called Assert.email
in scope, so it stays metadata; reflect.fields(CreateUserDTO) returns
each field's decorator list as {name, args, namedArgs, ...} dicts.
Resolution rule. When the runtime sees @foo on a field, it
looks up foo in scope. Callable -> write barrier. Unresolved ->
metadata only. The same name can mean different things in different
scopes; that's fine.
Decorator arguments. Literal values (strings, ints, floats, decimals, bools, null) and literal list / dict / set composites built from those. Names from scope are not resolved at decorator-arg time, so the metadata stays serialisable into the compiled chunk.
class Item {
@Assert.range(1, 100)
@Groups("read", "write", "admin")
int quantity;
}
@Assert.range(1, 100) to metadata {name: "Assert.range", args: [1, 100]}.
@Groups("read", "write", "admin") to metadata
{name: "Groups", args: ["read", "write", "admin"]}.
Parameter-level decorators
Parameter decorators are metadata-only. They never run, never
wrap the value, never alter the call. They sit on the parameter
and reflect.parameters(target) surfaces them so frameworks can
read them back.
class DbConn {
func DbConn(@Param("db.url") string url) { ... }
}
@Get("/users/{id}")
func show(
@PathParam("id") string id,
@QueryParam("limit") int limit = 10,
@Header("X-Api-Key") string apiKey
): User { ... }
Same arg rules as field decorators: literal strings, ints, floats, decimals, bools, null, and literal list/dict/set composites of those. Names from scope are not resolved.
Reflection:
for (p in reflect.parameters(show)) {
if ((p as dict<string, any>).contains("decorators")) {
for (d in p["decorators"] as list<any>) {
io.println((d as dict<string, any>)["name"]);
}
}
}
/* PathParam, QueryParam, Header */
The decorators key is only present on parameters that carry at
least one. The dict per decorator has the same shape as
reflect.decorators(target) returns: name, args, namedArgs,
target ("parameter"), position, overload, line, column.
Parameter decorators work on constructor parameters, method parameters, free-function parameters, and lambda parameters - one rule, every parameter list.
Reading decorator metadata
| Call | Returns |
|---|---|
reflect.decorators(target) |
List of {name, args, namedArgs, line, column} dicts. |
reflect.hasDecorator(target, name) |
Bool. true when at least one decorator with that name is present. |
reflect.decorator(target, name) |
First decorator dict with that name, or null. |
reflect.fields(class) |
List of field dicts; each has a decorators key when at least one annotation is present. |
target is either a class value, a function/method value, or a class
instance (in which case the call delegates to the instance's class).
Names match exactly: @Assert.email is the name "Assert.email",
not "Assert" with a sub-key.
Built-in decorator: @abstract
@abstract marks a class or method as not directly instantiable.
A class is abstract when either:
- the class itself is decorated
@abstract, or - it has (or inherits) a method decorated
@abstractwith no concrete override in any more-derived class.
Direct construction of an abstract class throws RuntimeError.
@abstract
class Repository {
func describe(): string { return "repo"; }
}
class Storage {
@abstract
func read(string key): string { return ""; }
}
class MemoryStorage extends Storage {
func read(string key): string { return "..."; }
}
Repository(); /* throws: cannot instantiate abstract class Repository */
Storage(); /* throws: cannot instantiate Storage: abstract method
Storage.read is not implemented */
MemoryStorage(); /* OK - read() is overridden */
Method bodies on @abstract methods are still parsed (Geblang has no
abstract keyword) and they execute if a concrete subclass calls
parent.read(key) against them, so it's reasonable to put a sensible
default or an explicit throw in there.
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.
Subscript access (__index, __setIndex)
__get/__set handle dot access (obj.name). To make a class usable with
[] subscript syntax (like a dict or list), implement __index for reads and
__setIndex for writes:
class Headers {
dict<string, string> values;
func Headers() { this.values = {}; }
func __index(string key): ?string {
return this.values.get(key);
}
func __setIndex(string key, string value): void {
this.values.set(key, value);
}
}
let h = Headers();
h["Content-Type"] = "application/json";
io.println(h["Content-Type"]); # application/json
io.println(h["missing"]); # null
A class without __index is not subscriptable (obj[key] raises "not
indexable"), so subscript behaviour is fully opt-in.
Implement __contains(key) to support the in membership operator
(key in obj). For a full dict-like object, implement the maps.DictInterface
stdlib interface (__index + keys, optional __setIndex) and inherit
contains/get/values/length/isEmpty and __contains as defaults (see
the utilities chapter).
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.
Defining a single ordering dunder is enough for all four comparison
operators. Missing operators are derived: a > b falls back to
b.__lt(a) (and a < b to b.__gt(a)), while a <= b and a >= b
fall back to the negated strict comparison. With only __lt on Money
above, <, >, <=, and >= all work. A defined dunder always wins
over a derived one.
Cast Overloading
A class can control how its instances respond to as TYPE casts by
defining a cast dunder for each target primitive:
__string(): stringforas string__int(): intforas int__float(): floatforas float__bool(): boolforas bool__decimal(): decimalforas decimal__bytes(): bytesforas bytes
Each dunder must declare the matching return type. The semantic analyzer rejects mismatches at compile time, and the runtime checks the actual returned value as a defensive backstop.
class Money {
int cents;
func Money(int cents) {
this.cents = cents;
}
func __string(): string {
return "$" + (this.cents as string);
}
func __int(): int {
return this.cents;
}
func __bool(): bool {
return this.cents != 0;
}
}
let m = Money(550);
io.println(m as string); # $550
io.println(m as int); # 550
io.println(m as bool); # true
io.println(Money(0) as bool); # false
When the class does not define a dunder for the target primitive, the default cast logic runs (errors when the conversion is undefined for the receiver's type).
__string also drives implicit string rendering: an instance that
defines it is rendered through __string by string interpolation
("${m}"), io.println, and io.print, not only by an explicit
as string cast. A class without __string falls back to the default
inspection form (<ClassName>).
let m = Money(550);
io.println(m); # $550
io.println("paid ${m}"); # paid $550
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.
Iterator Protocol (__iter, __done, __next) (1.0.6)
A class becomes usable in for (x in obj) by implementing the iterator
protocol. Two methods drive each step:
__done(): boolreturnstruewhen iteration is exhausted.__next()returns the next value (called only when__done()isfalse).
A class that exposes both __done and __next directly is its own
iterator. To make a class iterable without making it the iterator
itself, also implement __iter() which returns the iterator (often a
fresh instance, or this after resetting internal state):
class Range {
int from;
int to;
int cur;
func Range(int from, int to) {
this.from = from;
this.to = to;
this.cur = from;
}
func __iter(): Range {
this.cur = this.from;
return this;
}
func __done(): bool {
return this.cur >= this.to;
}
func __next(): int {
int v = this.cur;
this.cur = this.cur + 1;
return v;
}
}
for (n in Range(2, 5)) {
io.println(n);
}
/* 2
* 3
* 4
*/
The loop calls __iter() once at the start to obtain the iterator,
then alternates __done() and __next() until __done() returns
true. __iter() can return any class instance that exposes
__done/__next; this lets a single iterable produce fresh
iterators for nested or repeated traversal.
When a class has no __iter() but does expose __next/__done, the
instance itself is used as the iterator. Useful for one-shot
iterators that should not be restarted.
User-defined iterables compose with iterable<T> parameters and slot
straight into the generator/list/dict iteration paths.
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),
Duplicate(string),
Failed(string)
}
let result = SaveResult.Duplicate("email");
let message = match (result) {
case SaveResult.Saved(string id) => "saved " + id;
case SaveResult.Duplicate(string field) => "duplicate " + field;
case SaveResult.Failed(string error) => error;
};
Backed enums
An enum can be backed by a distinct scalar value. Backing types are limited to
string and int, and every case in a backed enum must declare a unique
literal value:
enum Status: string {
Active = "active";
Closed = "closed";
}
Status.Active.value; # "active"
Status.from("active"); # Status.Active
Status.tryFrom("x"); # null
from(value) returns the matching variant or throws when no variant has that
backing value. tryFrom(value) returns the matching variant or null. The
argument type must match the enum backing type. A variant can have associated
data or a backing value, not both.
enum Code: int {
Ok = 200;
NotFound = 404;
}
Code.NotFound.value; # 404
Code.from(200); # Code.Ok
Enum methods
An enum may declare instance methods, callable on any variant. The body is the
variant list, then a single ;, then the method declarations:
enum Status {
Active, Suspended, Closed(string);
func isTerminal(): bool {
return match (this) {
case Status.Closed(string r) => true;
default => false;
};
}
func describe(): string {
return match (this) {
case Status.Active => "active";
case Status.Suspended => "suspended";
case Status.Closed(string r) => "closed: " + r;
};
}
}
let s = Status.Closed("fraud");
s.isTerminal(); # true
s.describe(); # "closed: fraud"
Inside a method this is the receiving variant, typed as the enum. Per-variant
behaviour is expressed with match (this) in one body; there is no per-variant
override. A method may call sibling methods on this. Methods sit beside the
existing data access: associated values and a backed scalar are read first, so a
method never shadows them. A method named like a built-in variant accessor
(variant, fields, and value on backed enums) is a compile error.
Enums remain immutable value types. A method cannot mutate the receiver; the bare
enum Name { A, B } form is unchanged.
Enums and interfaces
An enum may implement one or more interfaces with implements, mirroring the
class form (there is no extends for enums). Every interface method must be
satisfied by a declared enum method of matching arity, or the program is
rejected; an interface default applies when the enum leaves a method
unimplemented. A conforming enum value flows into an interface-typed slot, and
interface-typed dispatch lands on the enum's method:
interface Describable { func describe(): string; }
enum Status implements Describable {
Active, Closed(string);
func describe(): string {
return match (this) {
case Status.Active => "active";
case Status.Closed(string r) => "closed: " + r;
};
}
}
Describable d = Status.Active;
d.describe(); # "active"
Status.Active instanceof Describable; # true
Enum static surface
Two operations are available on the enum type itself:
EnumName.values()returns alistof the simple (nullary) variants, in declaration order.EnumName.fromName(s)resolves a variant by its exact name and returns?Variant: the matching variant, ornullwhen no variant has that name. The match is case-sensitive.- Backed enums additionally expose
EnumName.from(value)andEnumName.tryFrom(value)lookup by backing value.
enum Status { Active, Suspended, Closed }
Status.values(); # [Status.Active, Status.Suspended, Status.Closed]
Status.fromName("Suspended"); # Status.Suspended
Status.fromName("unknown"); # null
Status.fromName("active"); # null (case-sensitive)
Tagged variants are excluded from both: a bare name cannot construct a variant
that carries fields, so values() lists only the nullary variants and
fromName resolves only their names. Because fromName returns ?Variant, a
caller can supply a fallback at the call site without catching an error:
Status s = Status.fromName(input) ?? Status.Active;
Apart from values, fromName, and backed-enum from / tryFrom, the enum
surface is instance methods and interface implementation only.