Zornux docs
Get started Spec

Language

Classes (OOP)

Zornux reimagines objects with two everyday words: a class describes a kind of thing, and an item is one made from it. No new, no this, no self.

Defining a class

Fields are declared with has; behavior is a function inside the class. Inside a function, refer to fields by their plain name.

zornux
class Dog
    has name
    has breed

    function bark
        show name + " says Woof!"
    end
end
Typed and required fields

A field may declare a type and a rule: has name as text required, has age as number, has active as truth = true. Types are text, number, whole, truth, date, and datetime; required rejects a missing value. Untyped has name still works — a type is opt-in.

Creating items

Make an item with create … from, then set its fields and call its functions.

zornux
create my_dog from Dog
my_dog.name = "Barnaby"
my_dog.breed = "Golden Retriever"

my_dog.bark()        # Barnaby says Woof!

Constructors — when created

Give a class a when created block to set up each new item as it is made, and pass values with create … with. A parameter that shares a field's name initializes that field automatically.

zornux
class User
    has name = "?"
    has email = "?"

    when created with name, email
        # nothing to write — same-named parameters initialize both fields
    end
end

create alice from User with "Alice", "alice@example.com"

A constructor may validate its input and call the new item's own functions, but it can't give back a value. If it fails, the error is recoverable with try … catch error. A subclass with no constructor uses its parent's. To transform an incoming value, take a differently-named parameter — e.g. when created with raw then email = lowercase(raw).

Properties — get / set, validation, computed

A plain has field is a data slot: reading or writing it never runs code. Overlay a get/set accessor block and it becomes a property — reading or writing can run code, carry validation, or compute a value. Plain fields are unchanged; the behavior applies only where an accessor block is present, and get/set stay contextual words — no new reserved keyword.

zornux
class Signup
    has email as text
        get
        set

        required
        email
    end

    has age as number
        get
        set

        minimum 0
    end
end

Declarative validators reuse the record rules — required, not empty, minimum/maximum, minimum length/maximum length, range, email, url, and matches. They check the incoming value before the setter runs, so a failed write raises the recoverable ValidationFailed (ZX2504) and leaves the previous value unchanged — never a partial mutation, and the message never echoes the attempted value.

An accessor may carry a body. Inside a set, the incoming value is the contextual name value; a get takes no input and must give back a value. A getter with a body and no setter is a computed property — no backing storage, not an ORM column, read without parentheses.

zornux
class Product
    private has stored_name as text

    has name as text
        get
            give back stored_name
        end
        set
            require value is not equal to "" otherwise "Name is required."
            stored_name = trim(value)
        end
    end

    has price as number
        get
        set
        minimum 0
    end

    # Computed — read as  item.label , with no parentheses.
    has label as text
        get
            give back name + ": " + text(price)
        end
    end
end
Read-only, write-only, restricted

A get with no set is read-only (writing it raises ZX0957); a set with no get is write-only — a password that is set but never read back (reading raises ZX0958). An accessor may narrow visibility with private set, never widen it (ZX0963).

validate item runs every property's declarative rules over its current value without mutating it, returning the same is_valid / errors result a record gives. An optional class-level validate … end block adds whole-object rules that span several properties; it runs only under validate item, not on every assignment.

zornux
create p from Product
p.name = "  Widget  "     # setter trims → "Widget"
p.price = 9

create checked = validate p
show text(checked.is_valid)   # true
show p.label                  # Widget: 9   (computed, no parentheses)

The four pillars, reimagined

Inheritance — extends

A class can build on another and inherit its fields and functions.

zornux
class GuideDog extends Dog
    private has owner

    function assist
        show name + " is guiding " + owner
    end
end

A subclass overrides an inherited function simply by declaring one with the same name — there is no override keyword. Because anyone holding a Dog may in fact be holding a GuideDog, an override may not take away what the inherited function offered: it must accept every call the inherited one accepts (an added parameter needs a default — ZX0967), stay at least as visible (ZX0968), and stay the same kind of member — an item function never becomes a static one, or the reverse (ZX0969). All three are reported by zornux check, before anything runs.

zornux
class Dog
    function describe with prefix
        give back prefix + "dog"
    end
end

class GuideDog extends Dog
    function describe with prefix, suffix = ""   # legal: describe("a ") still works
        give back base.describe(prefix) + suffix
    end
end

Polymorphism

Iterate over a mix of items and call the same function — each item runs its own version.

zornux
for each animal in shelter
    animal.speak()       # each item answers in its own way
end

Encapsulation — access modifiers

Members are public by default. Opt into encapsulation with protected (visible to the class and its subclasses) or private (the declaring class only). The same modifiers work on fields and methods.

ModifierReachable from…
public (default)anywhere, including item.member
protectedthe class and any class that extends it
privatethe declaring class's own functions only
zornux
class Account
    has owner                  # public
    protected has balance = 0  # visible to subclasses
    private has pin            # this class only

    function set_pin with code
        pin = code             # ok — its own method
    end
end

class Savings extends Account
    function deposit with amount
        balance = balance + amount   # ok — protected reaches subclasses
        give back balance
    end
end
The dot is external

Reading item.member is always outside access, so it reaches public members only — encapsulated state is used by bare name inside the class hierarchy. A blocked access raises a clear, located diagnostic (ZX0904ZX0920) rather than silently corrupting state.

Abstraction

Hide internal complexity behind a simple function, so callers work with intent, not machinery.

zornux
my_car.start()       # the caller never sees the ignition sequence

Contracts — follows

A contract lists the functions (and fields) a class must provide. A class promises to satisfy it with follows, checked the moment it is declared — so a missing member is caught early, not at the call site.

zornux
contract Animal
    requires function speak
end

class Dog follows Animal
    function speak
        show "Woof"
    end
end

class Cat follows Animal
    function speak
        show "Meow"
    end
end

function make_speak with animal
    animal.speak()        # any Animal answers in its own way
end

A class may follow several contracts. requires function greet with who pins the call the contract promises — an implementation may add a parameter with a default, because that call still works. requires has name asks for something a caller can read: a plain field satisfies it, and so does a property with a get, including a computed one. Only a write-only property fails (ZX0965), because nothing can read it. Contract-based dispatch is ordinary polymorphism.

Contracts can follow contracts

A contract may build on others, and then requires everything they require, all the way up. A class that follows the outermost one must satisfy the whole chain — and is an instance of every contract along it. Two paths that require the same function are one requirement, not a conflict.

zornux
contract Printable
    requires function print
end

contract Exportable follows Printable
    requires function export_to with format
end

class Invoice follows Exportable
    function print
        show "invoice"
    end
    function export_to with format
        give back format + ":invoice"
    end
end

create i from Invoice
show text(i is an instance of Printable)   # true — through Exportable

Across modules

Classes and contracts are checked across the whole project, so a contract in one module and the classes that follow it in another behave exactly as they would in one file. A type may be named through an import alias wherever a type is named — when creating an item, as a parent, as a contract, and in a runtime type check.

zornux
module Billing

import Models as m

public class Receipt extends m.Document follows m.Printable
    public function print
        show "receipt"
    end
end

Relationship parameters — of T

What other languages call generics, Zornux writes as a relationship. A function can name the relationship between what it takes and what it gives back. of T introduces the name; follows — the same word you already know from contracts — says what a T is expected to behave like:

zornux
function clone of T follows Animal with value as T gives T
    give back value
end

function combine of T follows Animal and Serializable, U with first as T, second as U gives T
    give back first
end

Read it as a sentence: values related to T are expected to behave as an Animal. Constraints are joined with and, because inside an of clause the comma already separates one relationship parameter from the next.

A constraint says what you meant, not what is allowed

Callers never name Animal, runtime identity is unchanged, and an unknown value stays legal — nothing is rejected. This is documentation the toolchain can read, for editors and readers; it does not decide what the program does.

You rarely have to write any of it

The toolchain already infers relationships and shows them in word style — List of whole, Map of text to whole, List of Point — flowing element types through map, filter, and indexing, and inferring a lambda's result per call. Hover a value in an LSP editor to see it. You write of T only when you want to state the intent yourself.

Abstract classes

Mark a class abstract when it exists only to be built on: it can hold shared fields and functions but can't be made into an item directly. Create items from a class that extends it.

zornux
abstract class Shape
    function area
        give back 0
    end
end

class Square extends Shape
    has side = 0
    when created with side
    end
    function area
        give back side * side
    end
end

create sq from Square with 4
show text(sq.area())      # 16

Records and enums

A record is a lightweight value type: it compares by value (two records with the same contents are equal), validates with the same rules as a property, and — unlike a class — does not map to the ORM. An enum names a fixed set of members, reached as Enum.member; both stay contextual, so no new reserved word.

zornux
record Point
    has x
    has y
end

enum Suit
    hearts
    spades
    clubs
    diamonds
end

create origin from Point
origin.x = 0
origin.y = 0
show text(Suit.hearts)     # hearts
By value vs by identity

Records, numbers, text, lists, and enum members compare by contents; class items compare by identity (unless the class overrides equals). A duplicate enum member is ZX0947, and an enum can't be constructed (ZX0948).

Text and equality, your way

By default an item prints as <Class item> — it never exposes its fields — and is compares by identity. A class can override either with a well-named function.

zornux
class User
    has id = 0
    has name = "?"

    when created with id, name
        id = id
        name = name
    end

    function as_text
        give back name            # used by text(user) and show
    end

    function equals with other
        give back id is other.id  # used by  user is other
    end
end

create a from User with 1, "Alice"
create b from User with 1, "Alicia"
show text(a)         # Alice
show text(a is b)    # true — same id
Safe by default

An item is never turned into JSON automatically, and its default text hides every field, so private state can't leak through logs, output, or the debugger. as_text is the one place you choose what to reveal.

as_text renders an item — not an item inside a container. show user uses it; show [user] prints [<User item>]. That is deliberate: formatting a list, map, or set is a plain walk over values and stays one, so showing, logging, or inspecting a collection can never run your code — and therefore can never mutate state, perform I/O, or fail. When you want each element rendered its own way, ask for it.

zornux
show join(map(users, function with one
    give back text(one)
end), ", ")

Next: expose behavior to the world with Controllers.