Interfaces
Interfaces define abstract contracts of methods, accessors, and operators that classes can implement. Unlike class inheritance, which is strictly single-parent, a class in Zena can implement any number of interfaces.
Declaring an interface ​
An interface is declared using the interface keyword and contains member
signatures without implementation bodies:
interface Printable {
print(): void;
}
interface Sizable {
size: i32 { get; }
isEmpty(): boolean;
}
Member signatures ​
Interfaces can declare:
- Methods: Declared with parameter lists and return types, terminated by a
semicolon:zena
interface Task { execute(context: Context): boolean; cancel(): void; } - Accessors (properties): Declared using field or accessor block syntax:
- Read-only property:
name: Type;orname: Type { get; } - Read-write property:
var name: Type;orname: Type { get; set; } - Write-only property:
name: Type { set; }
- Read-only property:
- Operators: Declared with the
operatorkeyword followed by the symbol:zenainterface Indexable<T> { operator [](index: i32): T; operator []=(index: i32, value: T): void; }
Generic interfaces ​
Interfaces can declare type parameters, which are instantiated when implemented or referenced:
interface Stack<T> {
push(item: T): void;
pop(): T | null;
length: i32 { get; }
}
Signature variance rules ​
When a class or derived interface implements an interface method, Zena enforces sound subtyping variance:
- Return types (covariant): An implementation can declare a more specific return type than the interface signature.
- Parameter types (contravariant): An implementation can accept a more general parameter type than the interface signature.
- Mutable properties (invariant): Read-write properties must match the declared type exactly.
interface Producer<T> {
produce(): T;
}
class AnimalProducer implements Producer<Animal> {
// Returns Dog, which is a subtype of Animal (covariant refinement)
produce(): Dog {
return new Dog('Buddy');
}
}
implements ​
A class declares that it conforms to one or more interfaces using the implements
clause:
class Document implements Printable, Sizable {
var #text: String;
new(this.#text);
print(): void {
console.log(this.#text);
}
size: i32 {
get { return this.#text.length; }
}
isEmpty(): boolean {
return this.size == 0;
}
}
A class can extend a single superclass and implement multiple interfaces at the same time:
class WorkItem extends BaseItem implements Printable, Runnable {
// ...
}
Conformance checking ​
The compiler validates that an implementing class provides concrete implementations for every signature declared by its interfaces:
- Missing members: Omitting a required method or accessor causes a compile-time conformance error.
- Accessors satisfied by fields: A class field can satisfy an interface accessor
signature directly:
- An immutable field (
let x: i32or barex: i32) satisfies a read-only signaturex: i32 { get; }. - A mutable field (
var x: i32) satisfies a read-write signaturevar x: i32;orx: i32 { get; set; }.
- An immutable field (
interface Coordinate {
x: f64 { get; }
y: f64 { get; }
}
class Point implements Coordinate {
x: f64; // Plain immutable fields satisfy read-only Coordinate accessors
y: f64;
new(this.x, this.y);
}
Downcasting and runtime type tests ​
An interface reference can be tested or downcast back to a concrete class using is
and as:
let process = (p: Printable): void => {
if (p is Document) {
let doc = p as Document; // Unwraps the concrete instance
console.log('Document length: ' + doc.size.toString());
}
};
Under WebAssembly GC, the runtime unwraps the object instance stored inside the
interface fat reference and executes a native ref.test or ref.cast against the
target class type.
Interface inheritance ​
An interface can inherit from one or more base interfaces using the extends keyword:
interface Readable {
read(buffer: Array<u8>): i32;
}
interface Writable {
write(buffer: Array<u8>): i32;
}
// Multiple interface inheritance
interface ReadWriteStream extends Readable, Writable {
flush(): void;
}
- Member accumulation:
ReadWriteStreaminherits all signatures from bothReadableandWritablealongside its own members (flush()). - Subtype substitution: Any class that implements
ReadWriteStreamautomatically satisfiesReadableandWritable, and can be passed to functions expecting either parent interface.
Default members ​
Interfaces in Zena focus on pure type contracts, but they provide convenience features for instantiation and reusable behavior.
Interface factory constructors ​
An interface can declare constructors using new(...) or named constructors
(new name(...)). Unlike class constructors, interface constructors act as
factory functions:
- Their bodies must return an instance of a class that implements the interface.
- They have no
this, no initializer list, and nosuper()calls. - Callers invoke them using standard
new Interface(...)syntax.
interface Stack<T> {
new() {
return new ListStack<T>();
}
new withFirst(first: T) {
let s = new ListStack<T>();
s.push(first);
return s;
}
push(value: T): void;
pop(): T | null;
}
let s1 = new Stack<i32>(); // Stack<i32> backed by ListStack
let s2 = new Stack.withFirst(42); // Stack<i32>
Restating type parameters with tighter bounds ​
An interface may be unconstrained while its factory constructor requires bounds. A constructor can restate the interface's type parameters with additional constraints:
interface Map<K, V> {
new<K extends Hashable, V>(capacity: i32 = 16) {
return new HashMap<K, V>(capacity);
}
get(key: K): V | null;
set(key: K, value: V): void;
}
let m = new Map<String, i32>(); // OK: String implements Hashable
Narrower declared return types ​
An interface constructor can declare a return type narrower than the interface itself:
interface ReadOnlyBuffer {
new(): MutableBuffer {
return new MutableBuffer();
}
length: i32 { get; }
}
The caller gets the full API of MutableBuffer, while the interface remains the
abstract contract.
Implementing classes owe nothing to interface constructors; they are purely a convenience of the interface.
Default method implementations via mixins ​
While interfaces cannot provide instance method bodies directly, shared default implementations are expressed using Mixins:
interface Enumerable<T> {
forEach(fn: (item: T) => void): void;
}
mixin EnumerableMixin<T> on Enumerable<T> {
count(): i32 {
var total = 0;
this.forEach((_) => { total += 1; });
return total;
}
}
class MyList<T> with EnumerableMixin<T> implements Enumerable<T> {
forEach(fn: (item: T) => void): void {
// ...
}
}
The mixin requires on Enumerable<T>, allowing its default methods to call interface
members on this. Classes then gain the implementation simply by using with.
Representation ​
Understanding how interfaces are represented in WebAssembly GC explains their performance characteristics.
Why linear struct subtyping cannot represent interfaces ​
WebAssembly GC provides nominal struct subtyping through (sub $Base ...). However:
- Struct subtyping is single-inheritance only: a struct can declare at most one parent struct type.
- A class can implement multiple unrelated interfaces (e.g.
Printable,Sizable, andSerializable). - The field and method table offsets of multiple interfaces cannot be laid out in a single linear prefix without conflicts across unrelated classes.
Because of this, an interface cannot simply be a WebAssembly GC supertype of the classes that implement it.
Fat references ​
To represent an interface value uniformly at runtime, Zena uses fat references (interface objects). A fat reference is a two-field WebAssembly struct containing:
- Instance pointer (
$instance): An erased reference (anyref/eqref) to the actual heap object. - Interface table pointer (
$vtable): A reference to an Interface Table (itable) specific to that class's implementation of that interface.
;; Generated WebAssembly struct for an interface fat reference
(type $Printable (struct
(field $instance (ref null any)) ;; Concrete object
(field $vtable (ref $Printable_ITable)) ;; Interface dispatch table
))
Itables and trampolines ​
Each concrete class that implements an interface receives a singleton itable populated
with function references. Because the interface itable expects an erased any
receiver while the class method expects its specific struct type (ref $MyClass), the
compiler generates trampoline functions:
- The trampoline receives the erased
$instanceasany. - It casts the reference back to the concrete class (
ref.cast $MyClass). - It calls the real method on the class.
Performance implications ​
The fat reference model produces concrete performance trade-offs:
- Allocation overhead: Assigning a class instance to an interface variable
(
let p: Printable = doc) allocates a fat reference wrapper struct(instance, itable). - Extra indirection: Calling an interface method requires loading the itable from
the fat reference, loading the function pointer from the itable, loading the
instance, and executing
call_ref. This is one level of indirection more than a class virtual method call. - Class references are cheaper: Class references use native WebAssembly GC struct subtyping directly—they require no wrapper allocations, no trampolines, and zero pointer adjustments. Whenever possible, pass concrete class types rather than interface types in performance-critical code.
- Whole-program devirtualization: When the compiler's whole-program analysis proves that only a single concrete class ever flows into an interface-typed call site, it devirtualizes the call into a direct class function call, bypassing the fat reference and itable entirely.