Devlog · July 2026 · nlvm-demos

Liskov by the compiler, not by convention

"NL is a good fit for SOLID code" is a claim any object-oriented language can make. The interesting part is that one letter of SOLID — Liskov substitution — isn't just possible in NL, it's partly enforced: the compiler rejects an overriding method whose throws clause would let a subclass break a caller that only trusts the parent's contract. 07_liskov, the newest nlvm-demos entry, is built around exactly that rule.

The rule: E016 and E017

Per compiler.md § Exception inheritance rules, when a method overrides a parent method, its throws clause must cover every checked exception (a subclass of Exception that isn't a RuntimeException) the parent declares:

Runtime exceptions are exempt on both sides — an override is free to add or drop those, matching the leniency the checked/unchecked split already gives ordinary calls (see specs.md § Checked exception propagation). Only the checked half of a throws clause is covariant-checked.

That single relation — "child's declared type is the parent's type or a subtype of it" — is Liskov substitution stated for exceptions instead of for return/parameter types: code written against the parent's contract, catching the parent's declared exceptions, keeps working no matter which subclass it actually got at runtime, because the subclass is contractually barred from surprising it with something wider.

Building a demo around it

RecordSource is an abstract class with one contract: produce the next record, or fail with (a subtype of) a checked ImportException:

abstract class RecordSource {
    public abstract bool hasNext() const;
    public abstract string next() throws ImportException;
}

Two unrelated implementations satisfy it. MemorySource reads from an in-memory queue and keeps the exact same throws ImportException — the unremarkable case. CsvLineSource parses "field,field,field" lines and narrows to throws InvalidRecordException, a subclass of ImportException declared purely to carry the offending line:

public string next() throws InvalidRecordException {
    string line = this.lines.get(this.cursor);
    this.cursor = this.cursor + 1;

    string[] fields = line.split(",");
    if (fields.length() != 3) {
        throw new InvalidRecordException(
            "expected 3 comma-separated fields, got " + fields.length(),
            line
        );
    }
    return line;
}

Both are legal overrides — same type, or a narrower one — so Main.nl can hold a system.List<RecordSource> with one of each and read every source through a single catch (ImportException e). Neither the loop nor the catch clause knows which concrete next() ran; that's substitutability made to actually run rather than just argued about:

for (auto source : sources) {
    while (source.hasNext()) {
        try {
            string record = source.next();
            system.Out.print("  ok: " + record + "\n");
        }
        catch (ImportException e) {
            system.Out.print("  rejected: " + e.message + "\n");
        }
    }
}

Two ways to break it — and what the compiler says

To make the rule concrete rather than theoretical, I compiled two deliberately broken variants of the same override. Widening — replacing throws ImportException with the strictly broader throws Exception — is rejected immediately:

public string next() throws Exception {
    throw new Exception("boom");
}
// Error: E016 — Overriding method 'next' does not declare exception
// 'liskov.ImportException' from parent method

The message is precise about why: Exception is a supertype of ImportException, not a subtype, so it doesn't cover the parent's contract — any caller written against RecordSource that only knows how to catch ImportException would have no defense against a bare Exception leaking out of this override. Adding an unrelated checked exception alongside a valid one fails the same way, for the opposite reason:

public string next() throws ImportException, UnrelatedException {
    throw new ImportException("boom");
}
// Error: E017 — Overriding method 'next' declares exception
// 'liskov.UnrelatedException' not thrown by parent method

Both diagnostics are shown in the demo's README as illustrations rather than compiled sources — the demo itself has to stay green, so the broken variants live only in the write-up, quoted from an actual failed nlc run rather than guessed at.

Why this is worth pointing out

Checked exceptions and override covariance both exist in other languages, but the combination NL enforces is stricter than the one most checked-exception languages settle for. Java's rule only forbids widening: an override is free to declare fewer checked exceptions than its parent, including none at all, and the compiler won't object even though that quietly changes what a caller programming against the parent type can expect to catch. NL's E016 closes that gap from the other side — dropping a parent's checked exception without narrowing it to a covering subtype is rejected exactly like widening is. The two rules together mean the throws clause of an override is pinned to "the same contract, or a more specific one," full stop, which is the guarantee Liskov substitution actually asks for and Java's checked-exception rule only half-provides.


Source and README are in nlvm-demos/07_liskov. See the demos page for the rest of the set, and compiler.md § Exception inheritance rules for the rule's full specification.