Protocols
Declare what a type can do, conform to it on a namespace, and write functions that accept any type that conforms.
A protocol names a set of methods a type can promise to have. It is close to a TypeScript interface, with one difference: a type conforms only where a namespace declares it, never because its shape matches. The protocol you meet first is Printable. A type that conforms to it can be passed to Terminal.print and placed in a string hole.
implementation {
choice Route {
Home,
Article { slug: String },
}
namespace Route for Route is Printable {
toString() -> String {
<- match @ -> String {
case #Home { <- "/" }
case #Article { <- "/articles/{@.slug}" }
}
}
}
constant reading: Route = #Article({ slug = "protocols" })
Terminal.print(reading) § /articles/protocols
Terminal.print("You are on {reading}") § You are on /articles/protocols
}
Printable requires one method, toString(), which returns a String. The line namespace Route for Route is Printable does two things. It declares methods for the choice Route, and it declares that Route conforms to Printable. To conform is to have every method a protocol requires, and to say so. Terminal.print and the string hole both ask for a Printable value, so both accept a Route.
Remove the namespace and both lines are refused. Terminal.print(reading) is unsatisfied-bound, “Route does not conform to ‘Printable’”. The hole is interpolation-not-printable. JavaScript gives every object a toString, so a template literal prints [object Object] until you write one. A record already prints, but a choice has no such fallback.
Declaring a protocol
implementation {
protocol Describable {
describe() -> String
}
type Book = { title: String, pages: Integer }
namespace Books for Book is Describable, is Printable {
describe() -> String {
<- "{@.title}, {@.pages} pages"
}
toString() -> String {
<- @.title
}
}
constant ficciones: Book = { title = "Ficciones", pages = 224 }
Terminal.print(ficciones::describe()) § Ficciones, 224 pages
Terminal.print(ficciones) § Ficciones
}
protocol declares a name and, in braces, the methods the protocol requires. A method written as a signature alone, with no body, is a requirement. A conformance goes on a namespace: is and the protocol’s name, after the for target. A namespace can conform to several protocols, each with its own is, separated by commas. Books’ toString replaces a record’s own printing for a Book, which is why ficciones prints as its title.
The word is is also the name of a method, the one Equatable requires: 5::is(5).
The compiler checks a conformance at the namespace, not where it is used. A required method that is missing, or whose signature differs from the protocol’s, is nonconforming-namespace. Rename describe to summary and the namespace is refused:
[nonconforming-namespace]
Error: Namespace 'Books' does not conform to 'Describable'
╭─┤ books.es:8:30 │
│
8 │ namespace Books for Book is Describable, is Printable {
│ ─────┬─────
│ ╰─────── Method 'describe' is missing
───╯A changed signature gets the same code, with the label “Method ‘describe’ does not match the Protocol’s signature”. An overload block satisfies a requirement when one of its entries matches. Its other entries are ordinary methods of the namespace.
Only a namespace with a for target can conform. namespace Helpers is Printable is conformance-needs-target-type. The target can be any type, including one you did not declare. namespace Describing for Integer is Describable makes every Integer describable, and Integer itself is untouched.
A conformance that names an undeclared protocol is unknown-protocol. A file that declares the same protocol name twice is duplicate-protocol.
Bounds
A type parameter is the placeholder type a generic function declares, such as Item in <infer Item>, as Generics showed. A bound limits it to the types that conform to a protocol.
implementation {
protocol Describable {
describe() -> String
}
type Book = { title: String, pages: Integer }
namespace Books for Book is Describable {
describe() -> String {
<- "{@.title}, {@.pages} pages"
}
}
function announce<infer Item is Describable>(_ item: Item) -> String {
<- "Now: {item::describe()}"
}
constant ficciones: Book = { title = "Ficciones", pages = 224 }
Terminal.print(announce(ficciones)) § Now: Ficciones, 224 pages
}
<infer Item is Describable> is a bound, and it is two things at once. It is a demand on every call: announce accepts only a type that conforms to Describable. It is also a permission inside the body: item::describe() is allowed because the protocol declares describe. The body knows nothing else about Item. Without the bound, item::describe() is no-namespace-for-value, “No Namespace in scope targets Item.”
Inside the body, the type parameter can stand where a namespace would. Item.describe(item) is the same call as item::describe(), with the receiver passed first.
Declared, not structural
Having the method is not conforming. Delete is Describable from the namespace and leave describe where it is. ficciones::describe() still works. The call to announce does not:
[unsatisfied-bound]
Error: { title: String, pages: Integer } does not conform to 'Describable'
╭─┤ announce.es:20:17 │
│
20 │ Terminal.print(announce(ficciones))
│ ─────────┬─────────
│ ╰─────────── this binds a Type Parameter bound to 'Describable'
│
│ Help: Declare a Namespace 'for { title: String, pages: Integer } is Describable'.
│
│ Note: No Namespace in scope makes { title: String, pages: Integer } conform to 'Describable'.
────╯A describe for a catalogue and one for a log share a shape but make different promises. So a namespace states each conformance.
Compared with JavaScript.
interface Describable {
describe(): string
}
function announce<T extends Describable>(item: T): string {
return `Now: ${item.describe()}`
}
const logEntry = { describe: () => "debug dump" }
announce(logEntry) // accepted: the shape matches
TypeScript accepts logEntry because its shape matches. Essence needs a namespace that declares is Describable. implements becomes is, and you can write it for any type, not only a class you own. More on Coming from JavaScript › Namespaces and protocols.
One bound per type parameter
implementation {
protocol Shown is Printable, is Equatable {}
type Label = { text: String }
namespace Labels for Label is Shown {
toString() -> String {
<- @.text
}
is(_ other: Label) -> Boolean {
<- @.text::is(other.text)
}
}
function describeChange<infer Item is Shown>(
from before: Item,
to after: Item,
) -> String {
if before::is(after) {
<- "still {before}"
}
<- "{before} became {after}"
}
constant draft: Label = { text = "draft" }
constant final: Label = { text = "final" }
Terminal.print(describeChange(from draft, to final)) § draft became final
Terminal.print(describeChange(from draft, to draft)) § still draft
}
A type parameter takes exactly one bound. <infer Item is Printable, Equatable> does not bound Item twice. The comma starts a second type parameter, named Equatable. Item is then only Printable, so before::is(after) is method-not-on-protocol, and each call is uninferable-type-parameter. Writing the name twice — <infer Item is Printable, infer Item is Equatable> — is duplicate-type-parameter: only the last would hold, so neither is taken. TypeScript’s A & B has no counterpart: <infer Item is Printable & Equatable> is a syntax-error, Expected '>' but found '&'.
To demand two protocols, declare one that extends both, as Shown does, and bound by that. Conforming to Shown is declared by name like any other. With an empty body, Labels is refused, “Method ‘toString’ is missing”, even though every record already prints. An Integer prints and compares too, and passing one is still unsatisfied-bound. A combined protocol suits the types you declare. A namespace that makes Integer conform to Shown makes it Printable a second time, so Terminal.print(5) becomes ambiguous-conformance.
What a protocol is not
- Not a type.
constant shown: Printable = 5andList<Printable>areprotocol-as-type. A list of values of several types is a list of a union. - Not a value.
Equatable.isNot(3, 4)isprotocol-as-value. Outside its declaration, a protocol’s name goes only afteris, in a conformance, a bound, a condition or an extension. It also goes in a protocol pick such as::<Equatable>, and in an import or export block.
A bounded function is not a value either. constant say = announce is protocol-bound-function-value, and passing it, as in books::map(announce), is no-matching-overload. Call it inside a function literal instead: books::map((book) { <- announce(book) }). The last section of this page says why.
A bound on one overload entry
implementation {
namespace Shelves<infer Item> for List<Item> {
overload arranged {
<Item is Comparable>() -> List<Item> {
<- @::sort()
}
(by rank: (_ item: Item) -> Integer) -> List<Item> {
<- @::sort(by (first, second) {
<- rank(first)::compare(to rank(second))
})
}
}
}
constant products = [{ price = 20 }, { price = 5 }]
Terminal.print([3, 1, 2]::arranged()) § [1, 2, 3]
Terminal.print(products::arranged(by .price)::map(.price)) § [5, 20]
}
An entry of an overload block can carry a bound of its own. It names the namespace’s type parameter again, with is and no infer, before its parameter list — the parameter is still the namespace’s, still worked out from the receiver, and the entry only says what it must conform to. The bound applies to that entry alone, so products::arranged() is unsatisfied-bound: a record is not Comparable. Generics showed the library’s sort declared the same way.
What a requirement can be
implementation {
protocol Defaultable {
static default() -> Self
}
type Settings = { retries: Integer, verbose: Boolean }
namespace Settings for Settings is Defaultable {
static default() -> Settings {
<- { retries = 3, verbose = false }
}
}
function reset<infer Item is Defaultable>(_ current: Item) -> Item {
<- Item.default()
}
constant custom: Settings = { retries = 9, verbose = true }
Terminal.print(Settings.default().retries) § 3
Terminal.print(reset(custom).retries) § 3
}
Self inside a protocol stands for the conforming type, and the namespace writes that type out: Settings. A requirement can be an instance method, a static or an overload block of signatures. The static is called on the namespace, Settings.default(). Inside the bounded reset, it is called on the type parameter, Item.default(), the only way to reach a static requirement there.
A protocol cannot declare:
- A default on a parameter.
greet(_ name: String = "world") -> Stringisdefault-on-protocol-method. Write the default on the conforming method. A provided method can not carry one either, since a namespace may replace it and a bound call then runs the replacement. - A property.
size: Integerin a protocol is asyntax-error. A protocol holds methods only. To ask for a member, use a record type: a parameter typed{ name: String }accepts any record that has aname. - A type parameter.
protocol Container<Item>and a requirementtransform<infer Target>(…)are both syntax errors. - A pattern in a requirement’s parameter.
area(of { width, height }: …)with no body ispattern-without-body. A requirement has no body to bind the names in. A provided method has one, so it may take a parameter apart.
Provided methods
implementation {
protocol Measurable {
area() -> Integer
isEmpty() -> Boolean {
<- @::area()::is(0)
}
}
type Rectangle = { width: Integer, height: Integer }
type Square = { side: Integer }
namespace Rectangles for Rectangle is Measurable {
area() -> Integer {
<- @.width::multiply(with @.height)
}
}
namespace Squares for Square is Measurable {
area() -> Integer {
<- @.side::multiply(with @.side)
}
isEmpty() -> Boolean {
<- @.side::isLessThan(2)
}
}
function isBlank<infer Shape is Measurable>(_ shape: Shape) -> Boolean {
<- shape::isEmpty()
}
constant line: Rectangle = { width = 0, height = 4 }
constant speck: Square = { side = 1 }
Terminal.print(line::isEmpty()) § true
Terminal.print(isBlank(line)) § true
Terminal.print(speck::area()) § 1
Terminal.print(isBlank(speck)) § true
}
A method written with a body inside a protocol is provided. Every conforming type has it without writing anything: Rectangles writes area only, and line::isEmpty() runs the protocol’s body. A provided body is written on @, the conforming value. Its parameters may be patterns, as any method’s may: scaled(by { width, height }: Size) -> Integer { … } binds width and height in the body, and a namespace that replaces it may name the parameter whole.
A namespace that writes a method with the provided method’s name replaces it, whole. Squares writes its own isEmpty, and that is what runs, at a direct call and through a bound alike. isBlank(speck) returns true although speck’s area is 1. The replacement cannot call the provided body, and it is held to the provided signature, as a requirement is.
Compared with JavaScript.
abstract class Measurable {
abstract area(): number
isEmpty() { return this.area() === 0 }
}
class Square extends Measurable {
constructor(readonly side: number) { super() }
area() { return this.side * this.side }
isEmpty() { return this.side < 2 || super.isEmpty() }
}
A TypeScript interface cannot carry a body. A provided method is close to a default method on an abstract class, or a default in a Rust trait. Any type can take it by conforming, not only a subclass, and there is no super.isEmpty(). More on Coming from JavaScript › Namespaces and protocols.
What a provided body can call
A provided body knows @ only as the protocol. It can call:
- the protocol’s own methods, required or provided;
- the methods of every protocol this one extends;
- methods on what those calls return, such as
@::area()::isGreaterThan(100); - the standard library, such as
Number.sumover[@::area(), 10].
A method outside that set is method-not-on-protocol, reported at the protocol: “‘Measurable’ has no Method named ‘toString’”. Anything your program declares is provided-method-out-of-reach, a constant, a function or a namespace alike. That holds where the body never writes the namespace’s name too: a method of it called on a value, as @::size()::shout() calls the shout of your namespace Helpers for Integer, and a conformance it declares that the body needs, as a string hole needs the Printable conformance of the value in it. A provided method is compiled once, outside the programs that use its protocol, so its body can reach the standard library and nothing your program declares. Give the protocol a requirement instead, and let each conforming namespace reach the name.
A static or overload method in a protocol cannot carry a body, unwritable-provided-method. A static has no receiver for @ to stand for.
A protocol is exported and imported by name, like any declaration, and its provided methods come with it, as Modules teaches. A protocol is known by the module that declares it together with its name. An import alias is only another spelling of it, and two modules that each declare a Sized declare two protocols, even with the same methods: a namespace that conforms to one does not meet a bound on the other. A function bounded by a protocol another module declares needs no import of that protocol where it is called. The call needs the conformance in scope: the namespace that declares it, or the choice whose conformance is derived. A provided method called directly on a value still needs its protocol in scope, under any name. Two same-named protocols may each provide a method of one name, and each keeps its own body: a pick or a bound through either runs the one it names, as Calling a provided method sets out.
Calling a provided method
implementation {
Terminal.print("pear"::isNot("plum")) § true
Terminal.print(String.isNot("pear", "plum")) § true
Terminal.print("pear"::<Equatable>isNot("plum")) § true
Terminal.print(3::isLessThan(Number.Pi)) § true
Terminal.print(5::isBetween(1, and 3/2)) § false
Terminal.print(5::isLessThan(3)) § false
Terminal.print(5::<Orderable>isLessThan(3)) § false
}
Equatable provides isNot, and the first three lines call it three ways: on a value, on a namespace and through a pick. String.isNot("pear", "plum") names the namespace whose conformance offers it and passes the receiver first. "pear"::<Equatable>isNot("plum") names the protocol at the call, with the protocol pick ::<Equatable>. On a value of a known type, the pick reaches provided methods only, and a pick of a requirement is picked-requirement. For a requirement, name the namespace instead: String.is("pear", "plum"). On a value of a type parameter bounded by the protocol, item::<Equatable>is(other) reaches the requirement too, through the conformance the bound passes in.
A pick says which conformance answers, and the conformance decides the body, as it does for a bound through the same protocol. Orderable extends Comparable, so 5::<Orderable>isLessThan(3) runs the isLessThan that Comparable provides. Where the namespace writes the method, or a protocol extending the picked one provides it again, the conformance runs that, and so does the pick: Integer writes its own isNot, so 5::<Equatable>isNot(3) runs it. Two protocols that each write a body for the name, without one extending the other, each keep their own, and a pick or a bound through either runs the one it wrote. The value has to conform to the picked protocol: a String is Comparable but not Orderable, so "a"::<Orderable>isLessThan("b") is no-namespace-for-value.
Self in a provided body is the target type of the conforming namespace, not the receiver’s type. Integer declares its own isLessThan, for an Integer or a Rational. π is neither. So 3::isLessThan(Number.Pi) runs the isLessThan that Comparable provides through the Number namespace, which covers every numeric type. There Self is Number, and the two are compared as numbers. 5::isBetween(1, and 3/2) runs there too, because 3/2 is not an Integer. 5::isLessThan(3) runs Integer’s own method.
The pick is required when two protocols provide the same name. If Book conforms to Titled and Priced, and both provide label(), a bare ficciones::label() is ambiguous-namespace. ficciones::<Titled>label() picks one.
A bound runs one body. Where its protocol provides the method, that is the body a pick through the protocol runs: the body the protocol wrote or inherited, or one that a protocol extending the protocol that wrote it provides again. A protocol that does not extend the one that wrote the body never replaces it. Where the protocol only requires the method, of two protocols where one extends the other, the more derived one’s body runs. Otherwise the body of the first is that reaches one runs. Only a body whose signature fulfils the bound’s requirement, with Self as the namespace’s target type, counts, so every call runs the same body whatever value it passes. A value of a narrower type, such as an Integer where the namespace is for Number, needs that body to fulfil the requirement with Self as its own type too, and the methods the body calls to answer for that type as its own protocol declares them. It is nonconforming-namespace where they do not. A requirement that no body fulfils is owed by the namespace, nonconforming-namespace where it is missing. A value bounded by a protocol can be handed to a bound on one it extends, so the two have to run one body. Where these rules give them different bodies, as for a namespace conforming to two sides of a diamond that each provide the method again, the namespace is nonconforming-namespace, and writing the method in the namespace answers both. Whichever body runs can call every method of its own protocol, and each such call answers as that protocol declares the method.
Extending a protocol
implementation {
protocol Ordered is Comparable {
isBefore(_ other: Self) -> Boolean {
<- @::compare(to other)::is(Ordering#Less)
}
}
type Weight = { grams: Integer }
namespace Weights for Weight is Ordered {
compare(to other: Weight) -> Ordering {
<- @.grams::compare(to other.grams)
}
}
function lightestOf<infer Item is Ordered>(
_ items: List<Item>,
) -> Optional<Item> {
<- items::sort()::firstItem()
}
constant parcels: List<Weight> = [
{ grams = 300 },
{ grams = 120 },
{ grams = 450 },
]
Terminal.print({ grams = 1 }::isBefore({ grams = 2 })) § true
Terminal.print({ grams = 1 }::isLessThan({ grams = 2 })) § true
Terminal.print(lightestOf(parcels)::value(defaultingTo { grams = 0 }).grams) § 120
Terminal.print(parcels::sort()::map(.grams)) § [120, 300, 450]
}
protocol Ordered is Comparable extends Comparable. A protocol extends one or more others with the same is list a namespace uses. Extension does two things.
- Requirements are inherited. Conforming to
OrderedowesComparable’scompare(to:)too. Without it,Weightsisnonconforming-namespace, “Method ‘compare’ is missing”. - Conformance is granted down the chain. A namespace that declares
is Orderedis alsoComparable.sort()asks forComparableand accepts a list of weights, andisLessThan, whichComparableprovides, works on a weight.
A protocol can give its own body for a method that a protocol it extends provides. That body then runs, at a call and through a bound on the extended protocol alike. A method restated this way, with a body or as a requirement, keeps a signature the extended protocol accepts, since a value bounded by Ordered can be handed to a bound on Comparable, and a call there reaches it. Another signature, or an overload block, is incompatible-restatement, and so is one method that two extended protocols declare where neither signature accepts the other. Where one accepts the other, the extending protocol takes the one accepted, whichever clause comes first, with the body of the protocol it comes from, or, where that protocol only requires it, the last body of the others that fulfils it. A protocol that extends itself, directly or through others, is recursive-protocol. The where condition of the next section bounds a namespace’s type parameter, and a protocol has none, so an extension with one is where-on-protocol-extension.
protocol Ordered is Comparable reads like TypeScript’s interface Ordered extends Comparable. The difference is what comes along: Comparable’s provided methods arrive with the conformance.
Conditional conformance
implementation {
type Boxed<Item> = { value: Item }
namespace Boxes<infer Item> for Boxed<Item>
is Printable where Item is Printable,
is Comparable where Item is Comparable
{
toString() -> String {
<- "Boxed({@.value})"
}
compare(to other: Boxed<Item>) -> Ordering {
<- @.value::compare(to other.value)
}
}
constant boxes: List<Boxed<Integer>> = [
{ value = 3 },
{ value = 1 },
{ value = 2 },
]
constant nested = [{ value = { value = 2 } }, { value = { value = 1 } }]
Terminal.print(boxes::sort()) § [Boxed(1), Boxed(2), Boxed(3)]
Terminal.print(nested::sort()) § [Boxed(Boxed(1)), Boxed(Boxed(2))]
Terminal.print({ value = "pear" }::isLessThan({ value = "plum" })) § true
}
A List prints when its items print, because its namespace declares Printable, Equatable and Comparable on that condition. Boxes does the same: a box is printable when what it holds is, and comparable when what it holds is. Each conformance carries its own condition, written where Item is Printable after the protocol. Several conditions in one clause are separated by commas: where Left is Printable, Right is Printable. TypeScript has no counterpart. In Rust, this is impl<T: Display> Display for Box<T>.
The body relies on the condition. Without where Item is Printable, the hole in toString cannot print @.value, and the namespace is refused with interpolation-not-printable, “Type Parameter ‘Item’ does not conform to ‘Printable’”.
The compiler solves conditions at each call, one level at a time. nested sorts because Boxed<Boxed<Integer>> is Comparable, because Boxed<Integer> is, because Integer is. When the chain breaks, the error prints it as far as it got. Records are not Comparable. Add constant weights = [{ value = { grams = 300 } }] to the program, and weights::sort() is refused as unsatisfied-conformance-condition:
[unsatisfied-conformance-condition]
Error: { value: { grams: Integer } } does not conform to 'Comparable'
╭─┤ boxes.es:30:17 │
│
30 │ Terminal.print(weights::sort())
│ ───────┬───────
│ ╰───────── this binds a Type Parameter bound to 'Comparable'
│
│ Help: Declare a Namespace 'for { value: { grams: Integer } } is Comparable'.
│
│ Note 1: { value: { grams: Integer } } does not conform to 'Comparable'.
│ Note 2: { grams: Integer } does not conform to 'Comparable'.
────╯A few conditions are refused:
- A bound on the namespace’s own type parameter,
namespace Boxes<infer Item is Printable>, isprotocol-bound-namespace-generic. It would bound every method, including the ones that do not need it. A conformance carries one for the methods that fulfil it —is Printable where Item is Printable— and a method beside the requirements carries its own:biggest<Item is Comparable>(). See Generics › A namespace infers from its receiver. - A condition on a type parameter the namespace does not declare is
unknown-where-generic. - The same type parameter bound twice in one clause is
conflicting-where-condition. - A condition on a type parameter the target type never mentions is
unwitnessable-where-condition. The target type is what proves the condition at each call.
Unions in a bound
implementation {
protocol Describable {
describe() -> String
}
type Book = { title: String, pages: Integer }
type Song = { title: String, seconds: Integer }
type Work = Book | Song
namespace Books for Book is Describable {
describe() -> String {
<- "{@.title}, {@.pages} pages"
}
}
namespace Songs for Song is Describable {
describe() -> String {
<- "{@.title}, {@.seconds} seconds"
}
}
namespace Works for Work is Describable {
describe() -> String {
<- match @ -> String {
case Book { <- "a book called {@.title}" }
case Song { <- "a song called {@.title}" }
}
}
}
function announce<infer Item is Describable>(_ item: Item) -> String {
<- "Now: {item::describe()}"
}
constant work: Work = { title = "Blue in Green", seconds = 337 }
constant song: Song = { title = "Blue in Green", seconds = 337 }
Terminal.print(announce(work)) § Now: a song called Blue in Green
Terminal.print(announce(song)) § Now: Blue in Green, 337 seconds
}
A method call on a union needs no protocol. Without Works, work::describe() still runs: each member finds describe in its own namespace. A bound is different. Work is a type of its own, so it conforms only if a namespace says so, where TypeScript accepts a union whose members all fit. Without Works, announce(work) is unsatisfied-bound, “Work does not conform to ‘Describable’”. Works targets the union itself, and writes what the members have in common.
The last two lines hold the same record and print different text. The type the compiler knows picks the namespace, not the value at run time, and a direct work::describe() runs Works too. A namespace that names the type exactly is more specific than one that covers it, so Songs handles a Song. When two namespaces conform the same type to the same protocol, a bounded call is ambiguous-conformance.
Derived conformances for choices
A derived conformance is one the compiler writes for a choice itself.
| Protocol | Choice whose cases carry nothing | Choice with a payload |
|---|---|---|
Equatable |
derived | derived |
Enumerable |
derived | written: static cases() -> NonEmptyList<Self> under is Enumerable |
Printable |
derived once a namespace declares is Printable, even with an empty body |
written: toString() under is Printable |
implementation {
choice Colour {
Red,
Green,
Blue,
}
choice Shape {
Circle { radius: Integer },
Dot,
}
namespace Colour for Colour is Printable {}
namespace Shapes for Shape is Printable, is Enumerable {
toString() -> String {
<- match @ -> String {
case #Circle { <- "circle of {@.radius}" }
case #Dot { <- "dot" }
}
}
static cases() -> NonEmptyList<Shape> {
<- [#Dot, #Circle({ radius = 1 })]
}
}
function countOptions<infer Mode is Enumerable>(
_ example: Mode,
) -> Integer {
<- Mode.cases()::length()
}
constant chosen: Colour = #Green
constant drawn: Shape = #Circle({ radius = 2 })
Terminal.print(chosen) § Green
Terminal.print(Colour.cases()) § [Red, Green, Blue]
Terminal.print(countOptions(chosen)) § 3
Terminal.print(drawn) § circle of 2
Terminal.print(Shape.cases()) § [dot, circle of 1]
Terminal.print(countOptions(drawn)) § 2
Terminal.print(drawn::is(#Dot)) § false
}
Colour’s cases carry nothing, so it derives all three, printing through its empty namespace. If you know Rust, this is #[derive] done for you, with printing opt-in.
A choice with a payload derives neither Printable nor Enumerable, so Shapes writes both. Without Shapes, Shape.cases() is unknown-name. A choice’s name reaches cases(), derived or written, and no other static: a static default() in Shapes is called Shapes.default(), and Shape.default() is unknown-name.
A bare case, written without its choice’s name, binds a type parameter to that one case, not to the choice. countOptions(#Red) is refused, because one case has no cases to list:
[unsatisfied-bound]
Error: Colour#Red does not conform to 'Enumerable'
╭─┤ colours.es:44:17 │
│
44 │ Terminal.print(countOptions(#Red))
│ ─────────┬────────
│ ╰────────── this binds a Type Parameter bound to 'Enumerable'
│
│ Help: Annotate the value at 'Colour': a bare Case binds the Case, not the Choice.
│
│ Note: No Namespace in scope makes Colour#Red conform to 'Enumerable'.
────╯countOptions(Colour#Red) is refused the same way. Annotate the value at the choice, as chosen is.
A namespace that writes is for a choice must declare is Equatable, and one that writes cases for a choice whose cases carry nothing must declare is Enumerable. Otherwise it is undeclared-conformance. Those two are derived, so a call naming the namespace would run the written method, and a call through a bound the derived one.
The six standard-library protocols
| Protocol | Requires | Provides | Extends |
|---|---|---|---|
Equatable |
is(_ other: Self) -> Boolean |
isNot(_ other: Self) -> Boolean |
— |
Printable |
toString() -> String |
— | — |
Comparable |
compare(to other: Self) -> Ordering |
isLessThan, isLessThanOrEqualTo, isGreaterThan, isGreaterThanOrEqualTo |
— |
Orderable |
— | isBetween(_ lower: Self, and upper: Self) -> Boolean, clamp(between lowest: Self, and highest: Self) -> Self |
Comparable |
Enumerable |
static cases() -> NonEmptyList<Self> |
— | — |
Generatable |
static generate(from source: Randomness) -> Self |
shrink() -> List<Self> |
— |
compare returns an Ordering. Terminal.inspect takes any value, conforming or not. Property tests build values through Generatable, which Tests teaches. The library’s Protocols page lists every member and the types that conform to each.
How protocols compile
A protocol declaration compiles to nothing. What reaches the JavaScript is witnesses and provided methods. A witness is a plain object that maps each method name to the function that implements it. A bounded function takes one witness per bounded type parameter, as hidden last arguments, where TypeScript’s T extends P passes nothing. A provided method is compiled once, as one top-level function that takes the witness last. Here are isBefore and lightestOf from the extension example, built without minifying or optimising:
var $es_Ordered__isBefore = function(_self, other, Self__conformance) {
return choiceIs(Self__conformance.compare(_self, other), createCase("Ordering#Less"));
};
function lightestOf(items, Item__conformance) {
return $es_List_firstItem__overload$1($es_List_sort__overload$1(items, void 0, Item__conformance));
}
The witness exists only at a direct call. That is why a bounded function cannot be stored in a constant or passed as a value.