3. Types
What this is for
Someone writes a careful class. The balance may not go negative, and they say so:
class Account {
owner: String,
balance: Decimal<2>,
function open(owner: String, opening: Decimal<2>) -> Account
requires opening >= $0.00
{ return Account { owner: owner, balance: opening }; }
}
Three weeks later a test needs an overdrawn account. Nobody deletes the rule, nobody argues with it — they simply do not go through the door:
class Account { owner: String, balance: Decimal<2> }
let eve: Account = Account { owner: "eve", balance: -999.00 };
That compiles. And in that moment every requires in the class became a suggestion, because
there is a second way in that checks nothing. A reviewer looking at the diff sees one plausible
line in a test file.
Privacy without a constructor is a locked door in an open wall. This page is about building the wall.
Think of a vending machine
A vending machine shows you everything it holds. You can read every price, count every packet, and see exactly which shelf is nearly empty. What you cannot do is reach in.
There is one way to get something out, and it is the coin slot. The slot checks the coin. Nobody has to trust you, and nobody has to guard the machine, because the glass is not a rule — it is a wall.
private is the
glass; the constructor is the slot; requires is the slot checking the coin.A Burxt class is three pieces, and they only work together.
A step closer
The three pieces are:
- Fields and methods in the same block. A type’s data and what you may do with it are declared together, so the wall and the door are in one place.
private. A field nobody outside the class may name — which also stops a literal from setting it, and that second half is what makes it a wall rather than a convention.- A constructor is just a function with no
self. No special syntax, nonew, no initialiser list, no order to remember. It returns the type, and it may carryrequires.
Miss any one and the other two stop meaning anything. That is the whole content of this page; the rest is spelling.
In code
Piece one: fields and methods in the same block
class Item {
sku: String,
name: String,
price: Decimal<2>,
function (self) label() -> String {
return self.sku + " " + self.name;
}
}
let rice: Item = Item { sku: "RICE", name: "Rice 5kg", price: 52.75 };
print(rice.label()); // RICE Rice 5kg
What a type is and what it does, findable in one jump. If you write PHP, C#, TypeScript or Java, that is the shape you already expect.
A method may also be written outside the block — function (self: Item) label() -> String { ... }
— and that is what you need for a type someone else declared, in another file or in lib/. For
your own, the block is where they belong.
Piece two: private
class Account {
owner: String,
private balance: Decimal<2>,
function (self) statement() -> String {
return self.owner + " has " + to_string(self.spendable());
}
private function (self) spendable() -> Decimal<2> {
return self.balance;
}
}
From outside:
class Account {
owner: String,
private balance: Decimal<2>,
function open(owner: String) -> Account { return Account { owner: owner, balance: $0.00 }; }
}
let a: Account = Account.open("ada");
print(a.balance);
error: `Account.balance` is private: it is reachable only from `Account`'s own methods.
Read it through a method that `Account` provides, or drop `private` from the field
if it is part of the type's API.
The class is the scope, not the file. Another class’s method cannot reach in and neither can top-level code — but a method written outside the block on the same class still can, because the boundary is the type.
There is no file boundary to appeal to, and that is not an oversight: use is a text pre-pass that
concatenates files (page 8), so by the time anything is checked there are no files
left, only one long program. A class needs no such knowledge to be a boundary.
Piece three: a constructor is a function with no self
class Account {
owner: String,
private balance: Decimal<2>,
// No `self`, because it MAKES one. Called as `Account.open(...)`.
function open(owner: String, opening: Decimal<2>) -> Account
requires opening >= $0.00
{
return Account { owner: owner, balance: opening };
}
}
let a: Account = Account.open("ada", $100.00);
And now the literal from the top of this page has nowhere to go:
class Account { owner: String, private balance: Decimal<2> }
let eve: Account = Account { owner: "eve", balance: -999.00 };
error: `Account.balance` is private, so `Account` cannot be built here: a literal may set a
private field only inside `Account`. Give the class a constructor —
`function open(...) -> Account` in its body, called as `Account.open(...)` — which is
the point of making the field private.
A literal may name a private field only inside its own class. That single rule is what turns the other two into a wall.
Put together: a class that cannot lie about itself
class Account {
owner: String,
private balance: Decimal<2>,
function open(owner: String, opening: Decimal<2>) -> Account
requires opening >= $0.00
{ return Account { owner: owner, balance: opening }; }
function (self) withdraw(amount: Decimal<2>) -> Account
requires amount > $0.00
requires amount <= self.balance
{ return Account { owner: self.owner, balance: self.balance - amount }; }
}
let a: Account = Account.open("ada", $100.00);
let b: Account = a.withdraw($30.00);
balance cannot go negative. Not should not — cannot, and you can see why by reading the one
block: open is the only way to make one, withdraw is the only way to change it, and no literal
anywhere else can bypass either. Try it anyway and the contract names itself, with the clause you
wrote:
burxt runtime error: `requires amount <= self.balance` failed in `Account.withdraw`
There is no build mode that removes that check and no factory that skips it.
(Why this shipped after private rather than with it.)
Values, not references
Everything copies. Assigning copies, passing to a function copies, storing in a field copies. There is no hidden sharing to reason about and no aliasing bug to find:
class Line { label: String, unit: Decimal<2>, quantity: Int }
let widget: Line = Line { label: "widget", unit: $19.99, quantity: 3 };
let mutable b: Line = widget;
b.quantity = 10;
print(widget.quantity); // still 3
A literal must set every field. There is no default the compiler could invent that would be right for money, and no half-built object to be caught holding.
self gets a copy; mutable self writes through to the caller’s value:
class Counter {
n: Int,
function (mutable self) bump() -> Int {
self.n += 1;
return self.n;
}
}
Two spellings, two different promises, and the compiler holds you to whichever you wrote.
Bindings, and where a type may be left out
let count = 0; // Int
let price = $19.99; // Decimal<2>
let mutable running = 0; // Int, and reassignable
A let takes its type from its value. Nothing else infers. Parameters, return types, fields
and every contract are written down, because a signature is what everyone who calls it reads — and
one you have to compute is not one you can read.
The one exception is an array, and it is not about the element type:
let xs: [Int; 3] = [1, 2, 3]; // fixed: exactly three
let mutable lines: [String] = []; // growable
[1, 2, 3] obviously holds Ints. What it does not say is fixed or growable, and those are
different types with different storage and different rules — so an array binding says which.
Inference removes typing, not checking:
function wrong() -> Decimal<2> {
let money = $1.00; // Decimal<2>
let rate = 8.25%; // Decimal<4>
return money + rate;
}
error: cannot + Decimal<2> and Decimal<4>: scales must match. Burxt does not
silently rescale money.
And inference can never introduce rounding, because a rounding contract exists only if somebody wrote one. An inferred binding has no annotation to read, so hover in the editor is where its type lives — the honest trade is that the type did not disappear, it moved.
Enums: one value, several shapes
enum Status { Paid, Owing(Decimal<2>), Void }
function owed(s: Status) -> Decimal<2> {
match s {
Paid => { return $0.00; }
Owing(amount) => { return amount; }
Void => { return $0.00; }
}
}
match is exhaustive, and there is no wildcard. Leave an arm out and it does not compile;
write the same arm twice and it does not compile.
The missing _ is the point. Add a fourth variant a year from now and every match in the program
stops compiling until somebody says what it means there — which is the entire value of having sum
types at all. A wildcard trades that away for a few seconds today.
match works on Int, String and Bool too, so an ordinary switch does not have to be an
if / else if chain — which matters for a reviewer, because a chain of comparisons hides its shape
and a switch says these are the cases in one glance:
function describe(status: Int) -> String {
match status {
200 => { return "ok"; }
404 => { return "missing"; }
500 => { return "broken"; }
_ => { return "unknown"; }
}
}
Here _ is required — the exact opposite of the enum rule, and worth understanding rather than
memorising. An enum has a known, finite list, so listing all of it is the whole point and a
catch-all would throw away the error you want next year. Int cannot be enumerated, so a match
without a catch-all would be a hole with nothing to mark it. Same keyword, two rules, and each
message says which rule it is and why.
Interfaces: the one abstraction mechanism
interface Priced {
function price(self) -> Decimal<2>
function label(self) -> String
}
class Meal implements Priced {
dish: String,
cost: Decimal<2>,
function (self) price() -> Decimal<2> { return self.cost; }
function (self) label() -> String { return "meal: " + self.dish; }
}
class Meal implements Priced is the form to reach for: fields, methods and the promise in one
block. The standalone form is there for a class you cannot edit:
class Book { title: String, cost: Decimal<2> }
interface Priced {
function price(self) -> Decimal<2>
function label(self) -> String
}
implement Priced for Book {
function (self) price() -> Decimal<2> { return self.cost; }
function (self) label() -> String { return "book: " + self.title; }
}
Conformance is declared, not inferred. A type does not accidentally satisfy an interface by happening to have methods with the right names, and the compiler checks the implementation answers every signature, adds none, and matches each one exactly — parameter count, parameter types, return type and the receiver form.
Inside a class body or an implement block, function (self) price() needs no type: the header
already said which. Outside either, the method names its type, because there nothing else does.
Using an interface as a type
interface Priced { function price(self) -> Decimal<2> }
class Book { title: String, cost: Decimal<2> }
implement Priced for Book {
function (self) price() -> Decimal<2> { return self.cost; }
}
function show(item: Priced) -> Decimal<2> { return item.price(); }
An interface name as a type means any type that implements this, decided at run time — a value plus a table of the interface’s methods. Static dispatch is the default everywhere else and costs nothing; you pay for the indirection exactly where you asked for it. These are storable: bindings, fields, arrays.
Dependency injection is a field
No framework, no container, no annotation, no reflection:
interface Rates {
function rate_for(self, abroad: Bool) -> Decimal<4>
}
class TableRates implements Rates {
home: Decimal<4>,
abroad: Decimal<4>,
function (self) rate_for(abroad: Bool) -> Decimal<4> {
if abroad { return self.abroad; }
return self.home;
}
}
class FlatRates implements Rates {
everywhere: Decimal<4>,
function (self) rate_for(abroad: Bool) -> Decimal<4> { return self.everywhere; }
}
class Checkout {
rates: Rates, // the dependency IS the field
function (self) tax(subtotal: Decimal<2>, abroad: Bool) -> Decimal<2, RoundHalfEven> {
return subtotal * self.rates.rate_for(abroad);
}
}
let live_rates: TableRates = TableRates { home: 12.00%, abroad: 0.00% };
let live: Checkout = Checkout { rates: live_rates };
print(live.tax($100.00, false)); // 12.00
let stub_rates: FlatRates = FlatRates { everywhere: 5.00% };
let stubbed: Checkout = Checkout { rates: stub_rates }; // the seam a test uses
print(stubbed.tax($100.00, false)); // 5.00
Checkout never names either implementation and cannot. All it knows is that something answers
Rates.
One wrinkle to know before you meet it: the dependency must be bound to a variable first. An interface object borrows the storage of the value it points at, and a temporary has none:
interface Rates { function rate_for(self, abroad: Bool) -> Decimal<4> }
class FlatRates implements Rates {
everywhere: Decimal<4>,
function (self) rate_for(abroad: Bool) -> Decimal<4> { return self.everywhere; }
}
class Checkout { rates: Rates }
let c: Checkout = Checkout { rates: FlatRates { everywhere: 5.00% } };
error: a `dynamic Rates` must come from a variable — an interface object borrows the storage
of the value it refers to, and an expression has none.
Arrays
let fixed: [Int; 3] = [1, 2, 3]; // length is part of the type
let mutable grow: [Int] = []; // growable
let n: Int = push(grow, 42);
Bounds are always checked. An index the compiler can read is checked while compiling; the rest at run time, and a failure names the index, the length and the position:
burxt runtime error: index 3 is outside an array of 3 (at byte 81)
Walking one:
class Line { label: String, unit: Decimal<2>, quantity: Int }
let mutable lines: [Line] = [];
let n: Int = push(lines, Line { label: "widget", unit: $19.99, quantity: 3 });
for line in lines {
print(line.label);
}
The element is a copy, immutable, and scoped to the body, so nothing can be written back into
the array through it. break and continue work as they read.
The thing iterated must be a name or a field path (xs, self.items), never a call — the loop
reads it once per element, so a call would pay its cost on every pass. Bind it first.
If you need the position, while is still there. There is no for i in 0..n: a range is a second
construct with its own questions, and this already says it.
let mutable lines: [String] = [];
let n: Int = push(lines, "widget");
let mutable i = 0;
while i < len(lines) {
print(lines[i]);
i += 1;
}
Strings
A String is bytes. len counts bytes, byte_at reads one, substring takes a slice, + joins.
Interpolation is a join written differently: "total: {amount}". More in
Maps and strings.
Why it is built this way
A rule that can be bypassed is not a rule. That is the whole argument for making private block a
literal as well as a field read. A class whose invariant holds “as long as everybody goes through the
constructor” has an invariant that holds until somebody is in a hurry, and the person in a hurry is
increasingly not a person.
Everything a reviewer needs is in the declarations. You can read class Account’s header — its
fields, which are private, its constructor and its requires — and know what the type guarantees
without opening a single body. That is the same property burxt review
depends on, and it is why the pieces are declarations rather than conventions.
Value semantics remove a whole category of bug. A class is a value: assigning copies, and passing
copies. There is no aliasing, so there is no “who else is holding this object” question, no defensive
copying, and no IDisposable. The cost is real — see below — and the thing it buys is that a function
cannot change something you did not hand it.
No inheritance, and why
No base type, no extends, no super, no abstract class. This was decided and closed in
v0.0.46, not deferred: across thirty versions nothing needed it, and composition plus interfaces
did the job every time — the same conclusion Go and Rust reached.
| Instead of | Use |
|---|---|
| Shared fields | A field holding the common class |
| Shared behaviour | A function both types call |
| Shared contract | An interface, implemented by each |
| “Is-a” polymorphism | An interface used as a type |
What that buys is the absence of the fragile base class problem, of which parent did this method come from, and of constructors running in an order nobody remembers.
What it costs
Copies are copies. Value semantics mean passing a large class copies its bytes. For the sizes real
programs use this is faster than a pointer chase; for a genuinely large aggregate in a hot loop it is
something to know about. burxt layout x.bx prints the sizes.
No inheritance means some duplication. Two types that share behaviour share a function or an
interface, not a base class. Occasionally that is one more line than extends would have been.
A constructor is a convention, not a keyword. Account.open is a function with no self that
happens to return Account. Nothing forces you to write one — and if you do not, and the field is
private, the type simply cannot be built outside its own file. That is a good failure, but it is a
failure at the point of use rather than at the point of declaration.
There is no to_string you can implement. A class of yours has no display form: to_string
handles Int, Bool and Decimal only. A shown() method is the idiom, and it is a gap rather than
a decision.
Interfaces are the only abstraction. No generics-over-traits beyond bounds, no operator
overloading, no Deref. If an interface will not express it, the answer today is a function.
When you reach for it
| You want | Reach for |
|---|---|
| a value with an invariant that must hold | a class with private fields and a constructor carrying requires |
| a value with nothing to protect | a class with plain fields. No constructor needed |
| one value that is one of several shapes | an enum, and match will force every case |
| several types that answer the same question | an interface, and implements on each |
| to choose an implementation at a call site | an interface-typed field — that is dependency injection here |
| to swap an implementation at run time | dynamic Named as the field type |
| a fixed number of things | [T; N] — the length is part of the type |
| a growing number of things | [T], which lives in a region |
| something that might not be there | not null, which does not exist: Option<T> |
If you come from PHP, C# or Java, this is the translation table:
| What you would reach for | Where it is |
|---|---|
| Constructor | function open(...) -> Account in the class, called Account.open(...) |
| Validation while constructing | requires on it — checked on every call, quoting the clause when it fails |
| Static / class method | The same thing: a function in the class with no self |
| Private field, private method | private |
| Interface | interface, and class X implements Y |
| Dependency injection | An interface-typed field. The caller chooses, with a literal instead of a container |
Destructor / IDisposable |
Nothing to write — see Memory |
null |
Does not exist. Absence is a type: Option<T> |
| Inheritance | Deliberately absent — below |
examples/services.bx is that table as one running program.
Examples
A class that cannot lie about itself, all the way through. balance is private, open is the only
way to make one, withdraw is the only way to change one, and every rule is checked on every call:
class Account {
owner: String,
private balance: Decimal<2>,
function open(owner: String, opening: Decimal<2>) -> Account
requires opening >= $0.00
{ return Account { owner: owner, balance: opening }; }
function (self) withdraw(amount: Decimal<2>) -> Account
requires amount > $0.00
requires amount <= self.balance
{ return Account { owner: self.owner, balance: self.balance - amount }; }
function (self) shown() -> String { return self.owner + ": " + to_string(self.balance); }
}
let ada: Account = Account.open("ada", $100.00);
print(ada.shown());
print(ada.withdraw($30.00).shown());
ada: 100.00
ada: 70.00
withdraw returns a new Account rather than changing one, because a class is a value. ada is
still 100.00 after that last line.
And the wall, doing its job. This is the test file from the top of the page, refused:
class Account {
owner: String,
private balance: Decimal<2>,
function open(owner: String, opening: Decimal<2>) -> Account
requires opening >= $0.00
{ return Account { owner: owner, balance: opening }; }
}
let eve: Account = Account { owner: "eve", balance: $0.00 - $999.00 };
error: `Account.balance` is private, so `Account` cannot be built here: a literal may set a private field only inside `Account`. Give the class a constructor — `function open(...) -> Account` in its body, called as `Account.open(...)` — which is the point of making the field private.
--> account.bx:10:20
|
10 | let eve: Account = Account { owner: "eve", balance: $0.00 - $999.00 };
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Read the message: it does not merely refuse, it names the constructor you should have written and how to call it. That is the shape every refusal in this language aims for.
Next
Memory — where built values live, and the one idea here with no equivalent elsewhere.