The Loup Web Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: The Web Modes
  3. Chapter 3: Variables and Data Types
  4. Chapter 4: Functions
  5. Chapter 5: Control Flow
  6. Chapter 6: Collections
  7. Chapter 7: Structs
  8. Chapter 8: Enums and Pattern Matching
  9. Chapter 9: Classes and Objects
  10. Chapter 10: Interfaces
  11. Chapter 11: Generics
  12. Chapter 12: Error Handling
  13. Chapter 13: Modules and Packages
  14. Chapter 14: Template Fences — HTML and CSS
  15. Chapter 15: Async and Concurrency
  16. Chapter 16: Reactive UI
  17. Chapter 17: Web Components
  18. Chapter 18: DOM, Storage, Workers, and JavaScript Interop
  19. Chapter 19: SSR, Hydration, and Deployment
  20. Chapter 20: Memory Safety in Web Apps

Chapter 10: Interfaces

An interface is a contract: a list of methods that any type claiming to implement it must provide. Code written against the interface works with every implementation, so you can render a list of different shapes, validate a form field with a list of different rules, or swap an in-memory store for a real one in tests.

Loup interfaces are nominal: a class or struct implements an interface only when it says implements Name. Having methods with the right names is not enough. That makes them closer to Java and C# interfaces than to TypeScript's structural types or Go's implicit interfaces. Rust programmers can read interface as "trait" and implements as an impl … for that lives on the type's declaration. The normative text is in the Language Reference §1-11.

Declaring and implementing an interface

@mode(web.compiled)

interface Shape {
    func area(): float;
    func name(): string;
}

class Circle implements Shape {
    pub let radius: float;
    new(radius: float) { this.radius = radius; }

    pub func area(): float { return 3.14159 * this.radius * this.radius; }
    pub func name(): string { return "circle"; }
}

class Rect implements Shape {
    pub let w: float;
    pub let h: float;
    new(w: float, h: float) { this.w = w; this.h = h; }

    pub func area(): float { return this.w * this.h; }
    pub func name(): string { return "rect"; }
}

func totalArea(shapes: list[Shape]): float {
    var total = 0.0;
    for (s in shapes) {
        total = total + s.area();
    }
    return total;
}

func main(): void {
    let shapes: list[Shape] = [Circle(1.0), Rect(2.0, 3.0)];
    for (s in shapes) {
        println(i"{$ s.name() $}: {$ s.area() $}");
    }
    println(totalArea(shapes));
}

Listing 10-1: An interface, two implementations, and a function that works with both

circle: 3.14159
rect: 6
9.14159

The interface body lists method signatures ending in ;. Interface methods are always public, so pub is optional there; the implementing class still writes pub func. A class extends at most one parent class but can implement any number of interfaces: class Badge : Widget implements Shape, Named.

list[Shape] holds objects of different classes. Each call to s.area() dispatches to the method of the object's own class.

Forgetting a method

Every method without a body in the interface must be implemented. Leave one out and the class is rejected where it is declared:

This code does not compile
@mode(web.compiled)

interface Shape {
    func area(): float;
    func name(): string;
}

class Square implements Shape {
    pub let side: float;
    new(side: float) { this.side = side; }

    pub func area(): float { return this.side * this.side; }
}

func main(): void {
    let s: Shape = Square(2.0);
    println(s.area());
}
main.lpweb:8:1: error[E0001]: class 'Square' does not implement all methods of interface 'Shape'; missing: name(): string

The message lists the missing signature; add pub func name(): string { return "square"; }.

Getting a signature slightly wrong

The implementation must match the interface's parameter and return types exactly. Returning int where the interface says float is a different method, not an implementation of this one:

This code does not compile
@mode(web.compiled)

interface Shape {
    func area(): float;
}

class Square implements Shape {
    pub let side: int;
    new(side: int) { this.side = side; }

    pub func area(): int { return this.side * this.side; }
}

func main(): void {
    let s: Shape = Square(3);
    println(s.area());
}
main.lpweb:7:1: error[E0001]: method 'area' in class 'Square' has return type 'int', incompatible with interface 'Shape' return type 'float'

Loup never converts between int and float for you (Chapter 3). Either store side as a float, or convert in the method: return (this.side * this.side) as float;.

Matching methods are not enough

If you come from TypeScript or Go, you might expect a class with an area(): float method to count as a Shape automatically. It does not:

This code does not compile
@mode(web.compiled)

interface Shape {
    func area(): float;
}

class Square {
    pub let side: float;
    new(side: float) { this.side = side; }

    pub func area(): float { return this.side * this.side; }
}

func printArea(s: Shape): void {
    println(s.area());
}

func main(): void {
    printArea(Square(3.0));
}
main.lpweb:19:15: error[E0001]: argument 1 of 'printArea': expected 'Shape', found 'Square'

Declare the relationship: class Square implements Shape { … }. Nominal typing means that a type implements a contract only when its author says so, and the compiler then checks the whole contract at that one place.

Generic interfaces, and structs that implement interfaces

An interface can take type parameters, and a struct can implement an interface just as a class can. Here a form validator is any type that can check a value of some type T and maybe return an error message:

@mode(web.compiled)

interface Validator[T] {
    func check(value: T): Option[string];
}

class NotBlank implements Validator[string] {
    pub func check(value: string): Option[string] {
        if (value.trim() == "") {
            return Some("must not be blank");
        }
        return None;
    }
}

class MinLength implements Validator[string] {
    pub let min: int;
    new(min: int) { this.min = min; }

    pub func check(value: string): Option[string] {
        if (value.len() < this.min) {
            return Some(i"must be at least {$ this.min $} characters");
        }
        return None;
    }
}

struct MaxTemperature implements Validator[float] {
    limit: float;

    func check(value: float): Option[string] {
        if (value > this.limit) {
            return Some(i"must be at most {$ this.limit $}");
        }
        return None;
    }
}

func errors(value: string, rules: list[Validator[string]]): list[string] {
    var found: list[string] = [];
    for (rule in rules) {
        match rule.check(value) {
            Some(msg) => found.push(msg),
            None => {},
        }
    }
    return found;
}

func main(): void {
    let password: list[Validator[string]] = [NotBlank(), MinLength(8)];
    println(errors("   ", password));
    println(errors("hunter2", password));
    println(errors("correct horse", password));

    let oven = MaxTemperature(limit = 250.0);
    println(oven.check(275.0).unwrap_or("ok"));
}

Listing 10-2: A generic Validator[T] interface with class and struct implementations

["must not be blank", "must be at least 8 characters"]
["must be at least 8 characters"]
[]
must be at most 250

Validator[string] and Validator[float] are different interfaces: a list[Validator[string]] cannot hold a MaxTemperature. Chapter 11 covers type parameters in general.

Default methods and interface inheritance

An interface method can have a body. Implementers inherit it and may replace it. An interface can also extend other interfaces with :, and an implementer must then provide the methods of all of them (Language Reference §1-11.1):

@mode(web.compiled)

interface Named {
    func name(): string;
}

interface Priced : Named {
    func cents(): int;

    func label(): string {                       # default method
        return i"{$ this.name() $} ({$ this.cents() $} cents)";
    }
}

class Item implements Priced {
    pub let title: string;
    pub let price: int;
    new(title: string, price: int) { this.title = title; this.price = price; }

    pub func name(): string { return this.title; }
    pub func cents(): int { return this.price; }
}

class GiftCard implements Priced {
    pub let amount: int;
    new(amount: int) { this.amount = amount; }

    pub func name(): string { return "gift card"; }
    pub func cents(): int { return this.amount; }
    pub func label(): string {                   # replaces the default
        return i"{$ this.amount // 100 $} dollar gift card";
    }
}

func main(): void {
    let cart: list[Priced] = [Item("notebook", 450), GiftCard(2500)];
    for (p in cart) {
        println(p.label());
    }
}

Listing 10-3: A default method, an interface that extends another, and an override of the default

notebook (450 cents)
25 dollar gift card

Default methods let an interface grow without breaking existing implementers: add a method with a sensible body and every implementation still compiles.

Spec and compiler disagree. The Language Reference (§1-8.3.1) says a method that replaces an interface's default method must be marked override. Stage0 1.25.1 rejects override there with E_OVERRIDE_NO_TARGET ("class GiftCard has no supertype"), so Listing 10-3 leaves it off. override is still required, and accepted, when a class replaces a method inherited from a parent class (Chapter 9).

Checking which interface a value implements

x is I tests whether x's runtime type implements interface I. As with classes (Chapter 9), Loup 1.25 does not narrow the variable's type after the test, so use as? to get a value of the narrower type:

@mode(web.compiled)

interface Named {
    func name(): string;
}

interface Priced : Named {
    func cents(): int;
}

class Item implements Priced {
    pub let title: string;
    pub let price: int;
    new(title: string, price: int) { this.title = title; this.price = price; }
    pub func name(): string { return this.title; }
    pub func cents(): int { return this.price; }
}

class Customer implements Named {
    pub let email: string;
    new(email: string) { this.email = email; }
    pub func name(): string { return this.email; }
}

func describe(n: Named): string {
    match n as? Priced {
        Some(p) => { return i"{$ p.name() $} costs {$ p.cents() $} cents"; },
        None => { return i"{$ n.name() $} has no price"; },
    }
}

func main(): void {
    let pen = Item("pen", 250);
    println(describe(pen));
    println(describe(Customer("ann@example.com")));
    println(pen is Named);
}

Listing 10-4: Testing for an interface with is and converting with as?

pen costs 250 cents
ann@example.com has no price
true

If you find yourself writing many of these checks, consider adding a method to the interface instead, so each type answers the question itself.

Standard interfaces

The Language Reference defines a set of interfaces that the language itself uses (§1-11.2). These are the ones you will meet first, with what Loup 1.25 actually does with them.

Iterable[T] and Iterator[T]

A type that implements Iterable[T] works in a for loop. iter() returns an Iterator[T], whose next() returns Some(value) until it is exhausted and then None (Language Reference §1-11.3):

@mode(web.compiled)

class Countdown implements Iterable[int] {
    pub let start: int;
    new(start: int) { this.start = start; }

    pub func iter(): Iterator[int] {
        return CountdownIter(this.start);
    }
}

class CountdownIter implements Iterator[int] {
    var current: int;
    new(start: int) { this.current = start; }

    pub modifier func next(): Option[int] {
        if (this.current <= 0) {
            return None;
        }
        let value = this.current;
        this.current = this.current - 1;
        return Some(value);
    }
}

func main(): void {
    for (n in Countdown(3)) {
        println(n);
    }
}

Listing 10-5: Implementing the iteration protocol by hand

3
2
1

The iterator's next() changes its own state, so it is a modifier. The interface declares a plain next(); implementing it with a modifier method is allowed, because the implementation only adds a contract.

Most of the time you do not need two classes. A generator function (gen func, whose body uses yield) produces an Iterable[T] for you, and a class that wraps a list can hand out the list's own iterator:

@mode(web.compiled)

gen func countdown(start: int): Iterable[int] {
    var n = start;
    while (n > 0) {
        yield n;
        n = n - 1;
    }
}

class Playlist implements Iterable[string] {
    var songs: list[string] = [];
    new(songs: list[string]) { this.songs = songs; }

    pub func iter(): Iterator[string] {
        return this.songs.iter();
    }
}

func main(): void {
    for (n in countdown(3)) {
        println(n);
    }
    for (song in Playlist(["intro", "verse", "chorus"])) {
        println(song);
    }
}
3
2
1
intro
verse
chorus

Comparable[T] and ==

Comparable[T] has one method, compareTo(other: T): int, which returns a negative number, zero or a positive number. Implementing it makes <, >, <= and >= work on your type (Language Reference §1-5.1):

@mode(web.compiled)

class Version implements Comparable[Version] {
    pub let major: int;
    pub let minor: int;
    new(major: int, minor: int) { this.major = major; this.minor = minor; }

    pub func compareTo(other: Version): int {
        if (this.major != other.major) {
            return this.major - other.major;
        }
        return this.minor - other.minor;
    }
}

func newest(versions: list[Version]): Version {
    var best = versions[0];
    for (v in versions) {
        if (v > best) {
            best = v;
        }
    }
    return best;
}

func main(): void {
    let releases = [Version(1, 4), Version(2, 0), Version(1, 10)];
    let n = newest(releases);
    println(i"newest is {$ n.major $}.{$ n.minor $}");
    println(Version(1, 9) < Version(1, 10));
}

Listing 10-6: compareTo gives a class the ordering operators

newest is 2.0
true

The method is named compareTo; a method called compare does not satisfy the interface. Generic code bounded by T: Comparable[T] cannot yet call compareTo or < on a T in Stage0 1.25.1 ("type 'T0' has no method 'compareTo'"), which is why newest takes a list[Version] rather than a list[T].

The Language Reference says Comparable also supplies == in terms of compareTo. Stage0 does not supply it yet, and == on two class instances compares identity, so this check gives the wrong answer:

This code does not do what it should
@mode(web.compiled)

class Version implements Comparable[Version] {
    pub let major: int;
    pub let minor: int;
    new(major: int, minor: int) { this.major = major; this.minor = minor; }

    pub func compareTo(other: Version): int {
        if (this.major != other.major) {
            return this.major - other.major;
        }
        return this.minor - other.minor;
    }
}

func main(): void {
    let installed = Version(1, 2);
    let required = Version(1, 2);
    println(installed == required);
}
false

Two separately constructed objects are two objects, even with equal fields. Define == yourself with operator func ==; Comparable[T] extends Equatable[T], which is what allows the operator:

@mode(web.compiled)

class Version implements Comparable[Version] {
    pub let major: int;
    pub let minor: int;
    new(major: int, minor: int) { this.major = major; this.minor = minor; }

    pub func compareTo(other: Version): int {
        if (this.major != other.major) {
            return this.major - other.major;
        }
        return this.minor - other.minor;
    }

    pub operator func ==(other: Version): bool {
        return this.compareTo(other) == 0;
    }
}

func main(): void {
    let installed = Version(1, 2);
    let required = Version(1, 2);
    println(installed == required);
}
true

A class that needs == but no ordering declares implements Equatable[T] and the same operator func ==. Without one of those interfaces, operator func == is E_OPERATOR_NO_INTERFACE.

⚠ Planned. Hashable (func hashCode(): int) is specified to let a class be used as a dict key or set element by value. In Loup 1.25 a class instance used as a key is looked up by identity, even when it implements Hashable and Equatable. Key dictionaries by a string or integer field instead.

Rendering an object as text

println(x), str(x) and {$ x $} render a class instance structurally, as Name(field: value, …) (Language Reference §1-16.4). A method named to_string() does not change that, which surprises Python (__str__) and Java (toString) programmers:

This code does not do what it should
@mode(web.compiled)

class Money {
    pub let cents: int;
    pub let currency: string;
    new(cents: int, currency: string) { this.cents = cents; this.currency = currency; }

    pub func to_string(): string {
        let whole = this.cents // 100;
        let frac = this.cents % 100;
        let pad = if (frac < 10) { "0" } else { "" };
        return i"{$ this.currency $} {$ whole $}.{$ pad $}{$ frac $}";
    }
}

func main(): void {
    let price = Money(1995, "USD");
    println(i"Price: {$ price $}");
}
Price: Money(cents: 1995, currency: "USD")

to_string() runs only when you call it:

@mode(web.compiled)

class Money {
    pub let cents: int;
    pub let currency: string;
    new(cents: int, currency: string) { this.cents = cents; this.currency = currency; }

    pub func to_string(): string {
        let whole = this.cents // 100;
        let frac = this.cents % 100;
        let pad = if (frac < 10) { "0" } else { "" };
        return i"{$ this.currency $} {$ whole $}.{$ pad $}{$ frac $}";
    }
}

func main(): void {
    let price = Money(1995, "USD");
    let fee = Money(305, "EUR");
    println(i"Price: {$ price.to_string() $}");
    println(i"Fee: {$ fee.to_string() $}");
}

Listing 10-7: Formatting an object by calling its method explicitly

Price: USD 19.95
Fee: EUR 3.05

⚠ Planned. The specified way to control how a type renders in println, interpolation and template fences is the Renderable interface, func render(ctx: RenderContext): string (Language Reference §1-16.4). In Loup 1.25, RenderContext is not available as a type, and the rendering pipeline does not call a class's render method, so implementing Renderable has no effect yet. Chapter 14 covers template fences.

Sendable

Sendable is a marker interface with no methods. It says that values of a type may cross an isolation boundary, such as a message to a web worker (Language Reference §2-1.10). Structs whose fields are all Sendable qualify, and you can say so explicitly with struct Point implements Sendable { x: int; y: int; }. The worker APIs that check it in the browser are planned (Chapter 18), so in Loup 1.25 the marker documents intent.

Async methods in interfaces

An interface method can be async (Language Reference §1-11.1). That is how you describe a data source whose real implementation will wait on the network while a test implementation answers at once:

@mode(web.compiled)

interface UserSource {
    async func displayName(id: int): Task[string];
}

class FakeUsers implements UserSource {
    pub async func displayName(id: int): Task[string] {
        return i"user-{$ id $}";
    }
}

async func greeting(src: UserSource, id: int): Task[string] {
    let name = await src.displayName(id);
    return "Welcome back, " + name;
}

async func main(): Task[void] {
    println(await greeting(FakeUsers(), 42));
}
Welcome back, user-42

On the Loup 1.25 interpreter, calling an async function runs its body immediately; Chapter 15 explains what that means for ordering.

A worked example: pluggable storage

A settings page needs to remember a few values. In tests you want an in-memory store; in the browser you will want one backed by localStorage. An interface lets the rest of the app not care. Here a third implementation wraps any other store and records what was written, a decorator in the classic sense:

@mode(web.compiled)

interface Storage {
    func get(key: string): Option[string];
    func set(key: string, value: string): void;
}

class MemoryStorage implements Storage {
    var items: dict[string, string] = {};

    pub func get(key: string): Option[string] {
        return this.items.get(key);
    }

    pub modifier func set(key: string, value: string): void {
        this.items[key] = value;
    }
}

class LoggingStorage implements Storage {
    var inner: Storage;
    pub var log: list[string] = [];

    new(inner: Storage) {
        this.inner = inner;
    }

    pub func get(key: string): Option[string] {
        return this.inner.get(key);
    }

    pub modifier func set(key: string, value: string): void {
        this.log.push(i"set {$ key $}");
        this.inner.set(key, value);
    }
}

safe func rememberLastPage(edits store: Storage, path: string): void {
    store.set("last_page", path);
}

func main(): void {
    let memory = MemoryStorage();
    let logged = LoggingStorage(memory);

    rememberLastPage(edits: logged, "/settings");
    rememberLastPage(edits: logged, "/profile");

    println(logged.get("last_page").unwrap_or("none"));
    println(memory.get("last_page").unwrap_or("none"));
    println(logged.log);
    println(memory.get("theme").unwrap_or("light"));
}

Listing 10-8: Three Storage implementations behind one interface

/profile
/profile
["set last_page", "set last_page"]
light

rememberLastPage does not know which store it was given. LoggingStorage holds another Storage and forwards to it, so both names see the same data.

The interface declares set as a plain method and the classes implement it as modifier, which is allowed (an implementation may add a contract). The Language Reference (§1-11.1) also lets the interface itself say modifier func set(...), so that every implementation must be a modifier and calls through the interface need a safe context. Stage0 1.25.1 does not parse modifier inside an interface body ("expected func in interface body"), and it does not apply the safe-context check to calls made through an interface type.

⚠ Planned. A LocalStorage class backed by the browser's storage (web.storage, Chapter 18) is the production implementation this design is meant for. No web.* module ships in Loup 1.25.

When to reach for an interface

  • Several types share behaviour, and callers should not care which one they hold (shapes, validators, stores).
  • You want third-party code to be able to plug in (a storage backend, a logger).
  • You want to swap a real implementation for a fake in tests.
  • You want to add an optional behaviour with a default method.

If there is only one implementation, a plain class is enough. Add the interface when the second implementation appears.

Try it yourself

  1. Write a Logger interface with info(msg: string) and error(msg: string). Implement ConsoleLogger with println, and BufferLogger, which collects messages in a list for tests.
  2. Add an Email validator to Listing 10-2 that requires an @ (use value.contains("@")), and run the password rules against an e-mail address to see which rules apply.
  3. Remove implements Comparable[Version] from Listing 10-6 and read the error you get for v > best.

Summary

  • An interface lists method signatures; a type implements it only by declaring implements (nominal typing), and the compiler checks the whole contract there.
  • Missing methods, mismatched signatures and undeclared implementations are compile errors with specific messages.
  • Interfaces can be generic (Validator[T]), can extend other interfaces, and can provide default methods. Stage0 rejects override on a replaced interface default, although the specification asks for it.
  • Use is to test for an interface and as? to convert; there is no narrowing after is yet.
  • Iterable[T]/Iterator[T] make a type work in for; a gen func is usually simpler.
  • Comparable[T] needs compareTo and gives </>; == on class instances is identity until you define operator func ==.
  • Rendering is structural; call your own formatting method explicitly. Renderable and value-based Hashable keys are planned.
  • Interface methods can be async. modifier methods inside an interface body are specified but not yet parsed.