Skip to main content

Protocols

A protocol names a set of operations. A type conforms to a protocol by providing an implementation of each of them, and a call to a protocol function then runs the implementation for the value it is given — compare means one thing for strings and another for numbers, and each call picks the right one at run time.

Protocols are how code gets written once against "anything that supports these operations": a smallest that works for every comparable type, a formatter that works for everything hashable. The alternative — a multi-clause function with one clause per type — requires editing the function each time a type is added. With a protocol, adding a type means declaring its conformance, and every existing call site picks it up.

Declaring a protocol​

A protocol declaration lists function and property requirements — signatures only, no bodies:

protocol Comparable {
function compare(self: Self, other: Self) -> "<" | "=" | ">"
}

Inside a protocol, the type Self stands for whichever type conforms. The first parameter of every protocol function must be Self — it is the value the call dispatches on. Writing the first parameter without a type means the same thing (function compare(self, other: Self)); explicitly typing it as anything else is the protocol-self-required error.

Protocols are engine-global, like named types: a protocol declared anywhere is visible everywhere after, and declaring one inside a local scope is protocol-scope-invalid. Re-executing a protocol statement — the notebook pattern — replaces the previous declaration and revalidates every implementation against the new requirements.

A protocol may also be empty. Such a marker protocol documents a semantic promise rather than an operation set, and a bare conformance declaration completes it:

protocol Copyable {}
type string is Copyable

Conforming a type​

The is keyword declares that a type conforms, and a braced block after it supplies the implementations:

⌘/Ctrl + Enter

In an implementation, Self and the conforming type's own name are synonyms — compare(self: Self, …) and compare(self: string, …) declare the same thing.

The conforming type must be a named, concrete type: a built-in (string, integer, list<integer>) or a declared nominal type. A union, an anonymous tuple or record shape, or a type alias name cannot conform (protocol-conformance-target-invalid) — wrap the shape in a nominal type first. A sum type is the one exception, and it is a spelling, not a new kind of conformer: see Conforming a sum type. A new nominal type can declare its conformance in the same statement:

⌘/Ctrl + Enter

Conformance may also be declared ahead of its implementation — declare in one statement (or one notebook cell), implement in a later one. Until the implementation arrives the conformance is pending: each program run that leaves it pending ends with a protocol-implementation-pending warning, and dispatching through it produces the ordinary protocol-implementation-missing error value.

An implementation block is checked as it lands: a member the protocol does not declare is protocol-member-unknown (with a "did you mean"), a missing one is protocol-implementation-missing, and a signature that does not match the requirement — after substituting the conforming type for Self — is protocol-signature-mismatch. Parameter types may be wider than the requirement and the result narrower; parameter names are not significant for matching. Implementing the same protocol twice for one type in a single program is protocol-implementation-duplicate; a later run replaces.

Conforming a sum type​

A sum type (type light = red | green | yellow) is a name for the union of its variants, and a union cannot conform. Write the conformance for the sum anyway: it declares the conformance once for each variant, with the same implementation block, and Self is that variant in each of them. It is the same as writing the block once per variant:

⌘/Ctrl + Enter

Because the conformance is per variant, dispatch is unchanged: a value of any variant finds the implementation, and each variant may still be given its own block instead.

Three rules follow from that:

  • If a variant already has its own implementation of the protocol, the sum block is a second implementation of that variant: protocol-implementation-duplicate, naming the variant. Nothing is registered — the sum spelling is all or nothing.
  • A variant the sum gains later — a second type shape = … | triangle statement in a later program or notebook cell — is given the same implementation as it is declared.
  • A generic sum (type tree<T> = leaf | node(value: T, kids: list<tree<T>>)) cannot be written this way: each variant is declared with only the type parameters its own payload uses, so there is no one spelling that fits every variant. Write the conformance for each variant.

Calling a protocol function​

A protocol function is called like any function. The implementation is chosen by the runtime type of the first argument, and the most specific conformance wins:

⌘/Ctrl + Enter

Subtypes inherit conformance: with only the number implementation declared, describe(3) still answers "a number" — an integer is a number, and the number implementation witnesses it. Declaring the integer implementation as well, as above, is not a conflict: it is a more specific implementation, and values that are integers get it. (Two conformances whose types overlap without one containing the other are rejected — protocol-conformance-overlap — because a value in the intersection would have no best implementation.)

Calling a protocol function on a value with no applicable implementation produces the protocol-implementation-missing error value; a call whose receiver's type cannot be decided yet simply stays symbolic until it can.

When the bare name is taken, qualify​

Two situations take the bare name away. A lexically visible definition of the same name shadows protocol members — your size wins over any protocol's. And two protocols can both declare a member that applies to the same receiver, making the bare call ambiguous. Both have the same escape hatch: qualify the member with the protocol's name.

compare("a", "b")
// -> protocol-call-ambiguous: `compare` applies to a value of type
// `string` through `Comparable(string)` and `Comparator(string)`.
// Use a qualified name to narrow the one you meant.

Comparable.compare("a", "b") // ➔ "<" — just Comparable's
Comparator.compare("a", "b") // ➔ -1 — just Comparator's

The qualified name is also a first-class value — pass it wherever a function is expected:

⌘/Ctrl + Enter

Named arguments work with protocol functions in both spellings, and the call dispatches on the argument bound to the declared first parameter wherever it is written: tag(prefix: "n", self: 5) and Tagged.tag(prefix: "n", self: 5) both dispatch on 5.

The dot form: c.area()​

A protocol function can also be called with the dot, the value first: c.area() is exactly area(c), and c.scale(2) is scale(c, 2). The value before the dot becomes the first argument, which is the argument the call dispatches on. Because any expression can be the receiver, calls chain from left to right:

⌘/Ctrl + Enter

The parentheses are what make the dot a call. Without them, c.area is a field or property read, and on a function member it is the protocol-function-not-a-field error; c.area is never a function value that remembers c. And the dot reaches members only: a field, a property, or a protocol function. A library function or a plain function is not a member of anything, so xs.Sort() is the error dot-call-not-a-protocol-function; write sort(xs), or chain such calls with the pipe, xs |> sort |> reverse.

Two details follow from the rest of the language. A field the receiver's type declares wins over a protocol function of the same name, so on a record or object whose field f holds a function, v.f(2) still calls the stored function. And a number literal never takes a dot (5.name() reads as 5. followed by name(), the same rule that makes 2.x a multiplication): bind the number to a name first.

When two protocols the type conforms to declare the same member, the bare call and the dot form are both protocol-call-ambiguous; the qualified dot form names the protocol: c.(Shape.area)(). And because the dot names a member, it reaches the protocol even when a definition of your own has taken the bare name (see above): with your own area in scope, area(c) calls yours and c.area() still calls the protocol's.

Properties​

A protocol can require properties, read with ordinary field syntax. readonly requires a getter; readwrite a getter and a setter:

⌘/Ctrl + Enter

A get implementation takes self and returns the property's type. A set implementation takes self and the new value, stores it, and returns the receiver:

⌘/Ctrl + Enter

The mutability gate​

Person above is an object type, and that is required rather than incidental: a writable property is meaningful only on a mutable object, so a protocol that can modify state — one with at least one readwrite property, or a function member whose declared effects include state — can be conformed to only by object types. A protocol with only readonly properties and no declared state can be conformed to by any type, as Signed is by number above.

protocol Identifiable { readwrite id: string }
type Badge = record{id: string} is Identifiable
// ➔ protocol-requires-object: the `Identifiable` protocol has settable
// properties. `Badge` is a record, and records are immutable; declare
// `Badge` as an object type to conform.

A bare requirement never gates — its effects are derived from whatever conformers exist, so a record may conform to a bare-function protocol with a pure implementation — and an explicit pure member never gates either, since the empty effect set is not state.

Assigning to a property is a store, and the assignment evaluates to the value assigned. The target does not have to be a variable: any expression that evaluates to an object can be stored into, so xs[1].name = "Ada" works when the list holds objects, and a const binding is no obstacle either — the store writes the object, never the binding. On a record, a tuple or any other immutable value it is immutable-value-assignment, which names the two ways forward: build an updated copy, or declare the type as object{…}. Providing a set implementation for a readonly property is protocol-property-readonly-set, and so is a write through the read-only protocol view — the qualified p.(Named.name) = v, or the unqualified p.name = v when name is a computed property. A readonly requirement that a stored FIELD satisfies is a different matter: readonly constrains that protocol's view of the field, not the object, so a holder of the object can still write the field directly. That asymmetry is deliberate for now and is under review (see the readonly entry in ROADMAP.md).

If two protocols declare a property with the same name, the qualified form disambiguates, for reads and for writes alike: person.(Nameable.name) and person.(Nameable.name) = "Ada".

Conditional conformance​

A parameterized type can conform only when its arguments do. The head names the type's variables, and the trailing where clause constrains them:

⌘/Ctrl + Enter

list<integer> conforms because integer does; list<string> does not, unless string is made Summable too. The conformance is recursive for free — list<list<integer>> conforms because list<integer> does, as the second call shows.

No effect specifiers on a conditional member​

A member of a conditional conformance may not carry an effect specifier. Its effects are inferred from its body instead:

protocol Summable { function total(self: Self) -> number }

type list<T> is Summable where T: number {
function total(self: Self) pure -> number { sum(self) }
}

That is refused when the conformance is declared, with protocol-conditional-member-effects. Drop the pure and the same block works — and total([1, 2, 3]) answers 6.

The restriction is specific to the conditional form. A conformance to a ground type accepts specifiers on every member:

⌘/Ctrl + Enter

The reason is that a conditional conformance's Self stands for a whole family of types (list<T>, not one type), and a specifier has to be recorded against a concrete receiver. The restriction is expected to lift; until then the failure is reported at the declaration rather than at the call.

Requiring conformance in a signature​

A generic function can require its type variable to conform, with the is slot of the where clause:

⌘/Ctrl + Enter

Multiple protocols are an and, joined with &: where T is Comparable & Hashable. A call whose solved type does not conform is rejected — smallest(True, False) above reports protocol-constraint-unsatisfied, naming the protocol and the type.

A protocol name is not a type: function sort(xs: list<Comparable>) is protocol-in-type-position, and the diagnostic shows the constrained spelling to use instead.

Diagnostics​

The protocol diagnostics carry their explanation in the message itself — each names the protocol, the type, and the way out. The full set of codes, grouped by when they fire:

  • Declaring: protocol-member-keyword-missing, protocol-self-required, protocol-scope-invalid.
  • Conforming: protocol-conformance-target-invalid, protocol-target-unknown, protocol-conformance-overlap, protocol-implementation-split (an implementation block on a multi-protocol is A & B — provide one block per protocol), protocol-requires-object (the mutability gate: a protocol that can modify state, conformed to by a non-object type), protocol-implementation-pending (a warning).
  • Implementing: protocol-implementation-missing, protocol-implementation-duplicate, protocol-member-unknown, protocol-signature-mismatch, protocol-property-readonly-set, protocol-conditional-member-effects (an effect specifier on a member of a conditional conformance).
  • Calling: protocol-call-ambiguous, protocol-property-ambiguous, protocol-constraint-unsatisfied, protocol-in-type-position, immutable-value-assignment (a property store on a value).