Chapter 11 · Define shared contracts that new types can implement.
Use an interface when a function should accept new types without knowing every type in advance. In this example, notify accepts a webhook from another package without adding a new branch.
The examples return strings and don't contact external services. You should be familiar with classes, unions, match, arrays, and lambdas.
For throws and error effects, see Handle errors. Each replacement below is a separate checked stage; the advanced examples are independent unless they explicitly reuse an earlier contract.
Send notifications with a closed union
Start with an application that supports only email and Slack. A union lets notify handle the complete set with an exhaustive match:
main.bamlclass EmailNotifier {
address: string,
function send(self, message: string) -> string {
`email to ${self.address}: ${message}`
}
}
class SlackNotifier {
room: string,
function send(self, message: string) -> string {
`slack to ${self.room}: ${message}`
}
}
type Notifier = EmailNotifier | SlackNotifier;
function notify(notifier: Notifier, message: string) -> string {
match (notifier) {
let email: EmailNotifier => email.send(message),
let slack: SlackNotifier => slack.send(message),
}
}class EmailNotifier {
address: string,
function send(self, message: string) -> string {
`email to ${self.address}: ${message}`
}
}
class SlackNotifier {
room: string,
function send(self, message: string) -> string {
`slack to ${self.room}: ${message}`
}
}
type Notifier = EmailNotifier | SlackNotifier;
function notify(notifier: Notifier, message: string) -> string {
match (notifier) {
let email: EmailNotifier => email.send(message),
let slack: SlackNotifier => slack.send(message),
}
}This design is useful when the application owns every route. Adding a member to Notifier makes the exhaustive match fail until notify handles the new case.
If other packages need to add routes, the central union becomes a coordination point. notify doesn't need the complete list; it needs one operation, send.
Replace the union with an interface
Replace the union with the smallest contract that notify needs:
main.bamlinterface Notifier {
function send(self, message: string) -> string throws never
}
class EmailNotifier {
address: string,
implements Notifier {
function send(self, message: string) -> string {
`email to ${self.address}: ${message}`
}
}
}
class SlackNotifier {
room: string,
implements Notifier {
function send(self, message: string) -> string {
`slack to ${self.room}: ${message}`
}
}
}
function notify(notifier: Notifier, message: string) -> string throws never {
notifier.send(message)
}interface Notifier {
function send(self, message: string) -> string throws never
}
class EmailNotifier {
address: string,
implements Notifier {
function send(self, message: string) -> string {
`email to ${self.address}: ${message}`
}
}
}
class SlackNotifier {
room: string,
implements Notifier {
function send(self, message: string) -> string {
`slack to ${self.room}: ${message}`
}
}
}
function notify(notifier: Notifier, message: string) -> string throws never {
notifier.send(message)
}Notifier defines the method available to callers. Each implements Notifier block opts a class into that contract and supplies its behavior. Matching method names alone don't make a class a Notifier, and an implementation that omits send doesn't compile.
The interface method states its error effect with throws never. An error effect is the set of errors that can leave a call; never means the set is empty. If you're familiar with Result<T, E>, string throws SendError is similar to an operation that produces a string or a SendError, while string throws never has no error case. Implementations can omit the clause when BAML infers an effect that satisfies the interface.
notify now calls the interface method directly. The concrete value still selects the email or Slack implementation at runtime.
Add a webhook outside its class
Suppose another package defines WebhookNotifier. Implement the local Notifier interface without editing the foreign class. The class declaration below represents that package’s source; keep it locally when running this example as a single project:
main.bamlclass WebhookNotifier {
url: string,
}
implements Notifier for WebhookNotifier {
function send(self, message: string) -> string {
`webhook to ${self.url}: ${message}`
}
}class WebhookNotifier {
url: string,
}
implements Notifier for WebhookNotifier {
function send(self, message: string) -> string {
`webhook to ${self.url}: ${message}`
}
}implements Notifier for WebhookNotifier adds a method-only capability outside the class body. The application can do this because it owns Notifier; notify doesn't change.
For an outside implementation, either the interface or the receiver type must be local. BAML also allows only one implementation for an interface and receiver pair. These coherence rules prevent dependencies from defining competing behavior for the same call.
Process different notifier types together
Use Notifier[] to store the three implementations and call send for each value:
main.bamlfunction notify_all(notifiers: Notifier[], message: string) -> string[] throws never {
notifiers.map((notifier) -> {
notifier.send(message)
})
}
function main() -> string[] throws never {
let notifiers: Notifier[] = [
EmailNotifier { address: "dev@example.com" },
SlackNotifier { room: "#deploys" },
WebhookNotifier { url: "https://example.com/hooks/deploy" },
];
notify_all(notifiers, "Build passed")
}function notify_all(notifiers: Notifier[], message: string) -> string[] throws never {
notifiers.map((notifier) -> {
notifier.send(message)
})
}
function main() -> string[] throws never {
let notifiers: Notifier[] = [
EmailNotifier { address: "dev@example.com" },
SlackNotifier { room: "#deploys" },
WebhookNotifier { url: "https://example.com/hooks/deploy" },
];
notify_all(notifiers, "Build passed")
}The function returns:
Inside the lambda, notifier exposes the Notifier contract, not route-specific fields such as address or url. Each call still uses the implementation for the concrete value.
Expose a common destination field
Interfaces can require fields. Map destination to the field that each class already stores. Add the following implements Destination blocks inside the earlier email and Slack classes, retaining their implements Notifier blocks. The excerpt shows only the fields and the new mappings:
main.bamlinterface Destination {
destination: string
}
class EmailNotifier {
address: string,
implements Destination {
destination as address
}
}
class SlackNotifier {
room: string,
implements Destination {
destination as room
}
}
function destinations(values: Destination[]) -> string[] throws never {
values.map((value) -> {
value.destination
})
}interface Destination {
destination: string
}
class EmailNotifier {
address: string,
implements Destination {
destination as address
}
}
class SlackNotifier {
room: string,
implements Destination {
destination as room
}
}
function destinations(values: Destination[]) -> string[] throws never {
values.map((value) -> {
value.destination
})
}destination as address exposes EmailNotifier.address through the interface name destination; the Slack mapping does the same for room. The interface doesn't add storage or rename the concrete fields.
Field mappings stay inside a class because they describe its representation. A foreign class can receive a method-only interface from outside, but another package can't reinterpret that class's fields without its owner participating.
Union and interface ownership
Choose the type based on who should own the next change:
| Requirement | Use |
|---|---|
| The consumer owns every possible type and must handle each one. | A union with an exhaustive match. |
| Adding a type should make existing consumers reconsider it. | A union. |
| Type owners opt into behavior that consumers already understand. | An interface. |
| New implementations should work with existing consumers unchanged. | An interface. |
The original union correctly modeled a closed email-and-Slack application. The interface became useful when webhook support could arrive from another package.
Interfaces instead of class inheritance
BAML doesn't support class inheritance. Email, Slack, and webhook notifiers share one operation, but they don't share construction, stored state, or an identity as one family. An interface expresses that relationship without requiring a common base class.
Class inheritance also makes one ancestry chain the main path for reusing behavior. In a deep hierarchy, understanding a method call can require tracing several overrides and super calls. A method can look local while its behavior depends on distant ancestors.
Multiple inheritance adds diamond and method-resolution questions. When two parents provide the same method, the language needs rules for which implementation wins and how a shared ancestor participates. Those rules add another lookup model for readers, compilers, and tools.
BAML keeps contracts separate from representation, following the same general approach as Rust traits. Classes opt into interfaces explicitly. Default methods reuse behavior through a contract, and same-named methods from different interfaces require an explicit choice. There is no inherited state or hidden override chain.
Advanced and reference
The notification example needs only a method contract, interface values, an outside implementation, and a field mapping. The following sections cover interfaces for reusable library behavior and generic APIs.
Add default behavior from a required interface
Use requires when a default method depends on another interface. This independent example replaces the earlier Destination, Notifier, and EmailNotifier definitions. It gives every Notifier a preview method that can read Destination.destination:
main.bamlinterface Destination {
destination: string
}
interface Notifier requires Destination {
function send(self, message: string) -> string throws never
function preview(self, message: string) -> string throws never {
`${self.destination} => ${self.send(message)}`
}
}
class EmailNotifier {
address: string,
implements Destination {
destination as address
}
implements Notifier {
function send(self, message: string) -> string {
`email: ${message}`
}
}
}
function default_email_example() -> string throws never {
EmailNotifier { address: "dev@example.com" }.preview("Build passed")
}interface Destination {
destination: string
}
interface Notifier requires Destination {
function send(self, message: string) -> string throws never
function preview(self, message: string) -> string throws never {
`${self.destination} => ${self.send(message)}`
}
}
class EmailNotifier {
address: string,
implements Destination {
destination as address
}
implements Notifier {
function send(self, message: string) -> string {
`email: ${message}`
}
}
}
function default_email_example() -> string throws never {
EmailNotifier { address: "dev@example.com" }.preview("Build passed")
}EmailNotifier implements both contracts. It supplies send; Notifier supplies preview. The function returns "dev@example.com => email: Build passed".
Preserve a concrete type with a generic bound
The next example reuses Notifier and EmailNotifier from the notification example. Use an interface type when callers need only the common contract. Use a generic bound when the return type must preserve the concrete input type:
main.bamlfunction erase_notifier_type(notifier: Notifier) -> Notifier {
notifier
}
function preserve_notifier_type<T extends Notifier>(notifier: T) -> T {
notifier
}
function type_examples() -> string[] throws never {
let email = EmailNotifier { address: "dev@example.com" };
let erased: Notifier = erase_notifier_type(email);
let preserved: EmailNotifier = preserve_notifier_type(email);
[erased.send("Build passed"), preserved.address]
}function erase_notifier_type(notifier: Notifier) -> Notifier {
notifier
}
function preserve_notifier_type<T extends Notifier>(notifier: T) -> T {
notifier
}
function type_examples() -> string[] throws never {
let email = EmailNotifier { address: "dev@example.com" };
let erased: Notifier = erase_notifier_type(email);
let preserved: EmailNotifier = preserve_notifier_type(email);
[erased.send("Build passed"), preserved.address]
}erase_notifier_type returns only the Notifier view, so address isn't available through erased. T extends Notifier keeps the concrete type, and BAML infers T from the argument.
Implement an interface for arrays
The following implementation gives every array a batch_size method:
main.bamlinterface BatchSize {
function batch_size(self) -> int throws never
}
implements<T> BatchSize for T[] {
function batch_size(self) -> int {
self.length()
}
}
function batch_sizes() -> int[] {
[["email", "slack", "webhook"].batch_size(), [1, 2].batch_size()]
}interface BatchSize {
function batch_size(self) -> int throws never
}
implements<T> BatchSize for T[] {
function batch_size(self) -> int {
self.length()
}
}
function batch_sizes() -> int[] {
[["email", "slack", "webhook"].batch_size(), [1, 2].batch_size()]
}T[] matches an array with any element type, so batch_sizes returns [3, 2].
The next example reuses the method-only Notifier and EmailNotifier from the notification example. Add a bound when the implementation needs behavior from each element. This implementation gives send_all only to arrays whose elements implement Notifier:
main.bamlinterface NotificationBatch {
function send_all(self, message: string) -> string throws never
}
implements<T extends Notifier> NotificationBatch for T[] {
function send_all(self, message: string) -> string {
self.map((notifier) -> { notifier.send(message) }).join(" | ")
}
}
function notify_email_batch() -> string {
[EmailNotifier { address: "dev@example.com" }, EmailNotifier { address: "ops@example.com" },]
.send_all("Build passed")
}interface NotificationBatch {
function send_all(self, message: string) -> string throws never
}
implements<T extends Notifier> NotificationBatch for T[] {
function send_all(self, message: string) -> string {
self.map((notifier) -> { notifier.send(message) }).join(" | ")
}
}
function notify_email_batch() -> string {
[EmailNotifier { address: "dev@example.com" }, EmailNotifier { address: "ops@example.com" },]
.send_all("Build passed")
}The bound makes send available inside the implementation. EmailNotifier[] receives send_all; int[] doesn't.
Select between same-named methods
When two interfaces provide the same method name, select the intended contract with as<Interface>:
main.bamlinterface CustomerLabel {
function label(self) -> string throws never
}
interface AuditLabel {
function label(self) -> string throws never
}
class EmailNotifier {
address: string,
implements CustomerLabel {
function label(self) -> string {
self.address
}
}
implements AuditLabel {
function label(self) -> string {
`email:${self.address}`
}
}
}
function notifier_labels() -> string[] {
let notifier = EmailNotifier { address: "dev@example.com" };
[notifier.as<CustomerLabel>.label(), notifier.as<AuditLabel>.label()]
}interface CustomerLabel {
function label(self) -> string throws never
}
interface AuditLabel {
function label(self) -> string throws never
}
class EmailNotifier {
address: string,
implements CustomerLabel {
function label(self) -> string {
self.address
}
}
implements AuditLabel {
function label(self) -> string {
`email:${self.address}`
}
}
}
function notifier_labels() -> string[] {
let notifier = EmailNotifier { address: "dev@example.com" };
[notifier.as<CustomerLabel>.label(), notifier.as<AuditLabel>.label()]
}An unqualified notifier.label() is ambiguous. The as<Interface> expression selects a contract; it doesn't convert the value.
Customize standard conversions
Every value has structural to_string() and to_json() behavior. Implement the standard interfaces to customize those operations:
main.bamlclass DeliveryReceipt {
route: string,
status: string,
implements baml.ToString {
function to_string(self) -> string {
`${self.status} via ${self.route}`
}
}
implements baml.ToJson {
function to_json(self) -> baml.json.json {
{ "route": baml.json.from(self.route), "status": baml.json.from(self.status) }
}
}
}
function receipt_representations() -> string[] {
let receipt = DeliveryReceipt { route: "email", status: "queued" };
[receipt.to_string(), receipt.to_json().to_string()]
}class DeliveryReceipt {
route: string,
status: string,
implements baml.ToString {
function to_string(self) -> string {
`${self.status} via ${self.route}`
}
}
implements baml.ToJson {
function to_json(self) -> baml.json.json {
{ "route": baml.json.from(self.route), "status": baml.json.from(self.status) }
}
}
}
function receipt_representations() -> string[] {
let receipt = DeliveryReceipt { route: "email", status: "queued" };
[receipt.to_string(), receipt.to_json().to_string()]
}The function returns ["queued via email", "{\"route\": \"email\", \"status\": \"queued\"}"].
BAML doesn't use ad hoc magic names such as __str__, toString, or toJSON. Define to_string inside baml.ToString and to_json inside baml.ToJson.
Define an associated error type
An associated type lets one interface implementation choose a related type once. This local PriorityComparison interface lets each implementation choose its comparison error. DeliveryPriority declares that comparison can't fail:
main.bamlinterface PriorityComparison {
type CompareError
function compare(self, other: Self) -> int throws Self.CompareError
}
class DeliveryPriority {
rank: int,
name: string,
implements PriorityComparison {
type CompareError = never
function compare(self, other: Self) -> int throws never {
if (self.rank < other.rank) { -1 } else if (self.rank > other.rank) { 1 } else { 0 }
}
}
}
function sorted_priorities() -> string[] throws never {
let priorities = [DeliveryPriority { rank: 2, name: "normal" }, DeliveryPriority { rank: 1, name: "urgent" }];
priorities.sort_by((left, right) -> { left.compare(right).cmp(0) }).map((priority) -> {
priority.name
})
}interface PriorityComparison {
type CompareError
function compare(self, other: Self) -> int throws Self.CompareError
}
class DeliveryPriority {
rank: int,
name: string,
implements PriorityComparison {
type CompareError = never
function compare(self, other: Self) -> int throws never {
if (self.rank < other.rank) { -1 } else if (self.rank > other.rank) { 1 } else { 0 }
}
}
}
function sorted_priorities() -> string[] throws never {
let priorities = [DeliveryPriority { rank: 2, name: "normal" }, DeliveryPriority { rank: 1, name: "urgent" }];
priorities.sort_by((left, right) -> { left.compare(right).cmp(0) }).map((priority) -> {
priority.name
})
}Self is DeliveryPriority in this implementation. CompareError = never is the error type used by compare; callers don't provide it when sorting. sort_by accepts a comparator returning baml.ops.Ordering; cmp(0) converts the negative, zero, or positive comparison result to that ordering. For a natural, infallible ordering, the standard interface is baml.ops.Compare.
Interface membership and coherence
Explicit membership. Matching fields and methods don't confer interface membership. This checked failure has the right field but never implements Destination:
main.bamlinterface Destination {
destination: string
}
class EmailNotifier {
destination: string,
}
function show_destination(value: Destination) -> string throws never {
value.destination
}
function main() -> string throws never {
show_destination(EmailNotifier { destination: "dev@example.com" })
}interface Destination {
destination: string
}
class EmailNotifier {
destination: string,
}
function show_destination(value: Destination) -> string throws never {
value.destination
}
function main() -> string throws never {
show_destination(EmailNotifier { destination: "dev@example.com" })
}Local ownership. An outside implementation is rejected when the current package owns neither the interface nor the receiver:
main.bamlimplements baml.ToString for int {
function to_string(self) -> string {
`integer ${self}`
}
}implements baml.ToString for int {
function to_string(self) -> string {
`integer ${self}`
}
}Single implementation. BAML rejects overlapping implementations for the same interface and receiver:
main.bamlinterface RouteLabel {
function route_label(self) -> string throws never
}
class EmailNotifier {
address: string,
implements RouteLabel {
function route_label(self) -> string {
self.address
}
}
}
implements RouteLabel for EmailNotifier {
function route_label(self) -> string {
`email:${self.address}`
}
}interface RouteLabel {
function route_label(self) -> string throws never
}
class EmailNotifier {
address: string,
implements RouteLabel {
function route_label(self) -> string {
self.address
}
}
}
implements RouteLabel for EmailNotifier {
function route_label(self) -> string {
`email:${self.address}`
}
}Together, explicit membership, local ownership, and overlap checking keep method selection stable when packages are combined.
Diagnostic reference
The main examples describe failures by cause. These are the codes emitted by the checked failure projects:
| Situation | Diagnostic |
|---|---|
Interface method omits an explicit throws clause. | E0170 |
| An implementation omits a required method. | E0113 |
A PriorityComparison implementation omits CompareError. | E0001 |
| A matching shape is passed without interface membership. | E0001 |
to_string or to_json is declared outside its standard interface implementation. | E0140 or E0142 |
| Neither side of an outside implementation is local. | E0139 |
| Two implementations overlap for one interface and receiver. | E0132 |
| A same-named interface method is called without disambiguation. | E0121 |
| A bounded blanket method is called on a receiver that doesn't satisfy the bound. | E0007 |