Skip to content

Defining Boundaries with Interfaces

The last two chapters took two commitments away from a class: how it stores its data, and what type of members it holds. One commitment has survived every design we have written. A variable is declared GuestList, a parameter is typed GuestList, and the code around it is bound to that one class even though it touches nothing except the public methods. The representation is hidden; the class itself is not.

An interface is an abstraction that enables classes to be hidden as well. It is a named type that lists a set of operations and says nothing about which specific class provides them. Code written against an interface depends only on the operations it names, so any class that provides those operations can be used, and the specific class that is used can change without the calling code being modified. This is the abstraction boundary in its purest form: a contract that records what is promised and deliberately omits everything about how the promise is kept.

This chapter introduces interfaces: the interface keyword and what belongs in one, how a class commits to an interface with implements, the difference between the type a variable is declared with and the type of the object actually behind it, and why depending on an interface rather than a concrete class is one of the most useful decisions in a design. It is also the foundation for the two chapters that follow, on polymorphism and on extending a system without modifying it.

A Channel as a Contract

We use one running example across this chapter and the next two: a small system that delivers alert messages to people over different communication channels.

As a monitoring system, I want to deliver an alert over each configured channel, so that the code that raises an alert never changes when a new channel is added.

Email and SMS differ in almost every respect, but for the purpose of raising an alert they have exactly one thing in common: each can deliver a message. That single shared capability, and nothing else, is what the alerting code should depend on. An interface lets us write that capability down as a type.

An interface declares a name and a list of method signatures:

typescript
interface <Name> {
    <methodName>(<parameters>): <ReturnType>;
}

Each line in the body is a method signature: a method's name, parameters, and return type, with no body. All interface methods are public. The interface records what operations exist, not how they work. For our channels:

typescript
/**
 * Delivers short alert messages over a single channel to a fixed recipient.
 *
 * A channel is responsible only for delivery. The recipient is decided when
 * the channel is constructed; callers decide only what to send.
 */
interface Notifier {
    /**
     * Delivers `message` over this channel, returning once delivery has
     * been attempted.
     *
     * @param {string} message the alert text; callers pass a non-empty string
     */
    send(message: string): void;
}

Notifier contains a single method signature and no fields. This is intentional: an interface describes the operations a caller may invoke but never its state or representation. Where the encapsulation chapter chose which methods a class exposes, an interface takes that public surface and gives it a name of its own, detached from any class.

plantuml Diagram

Because an interface is a contract that one body of code implements and another depends on, it is documented with care. Every member is part of a promise that callers rely on and implementers must keep, so the documentation that was good practice for a function is closer to essential for an interface.

interface Versus type

You have used type since Part 1 to name unions and the shapes of data, and TypeScript will in fact let you describe an object's shape with either type or interface. The convention this course follows is to use an interface for a contract that classes implement and callers depend on, and type for unions ("red" | "green" | "yellow") and for naming the shape of plain data. Use interface when several classes will commit to the same set of operations; use a type alias when you are giving a name to a structure.

Implementing the Contract

A class states that it satisfies an interface with the implements keyword:

typescript
class <ClassName> implements <InterfaceName> {
    // must provide every member the interface declares
}

The compiler then checks that the class provides every operation the interface declares, with compatible signatures; if it does not, the class does not compile. implements is therefore a promise the language holds the class to. Two classes can keep the Notifier promise in completely different ways:

Each class has its own private representation (an email address, a phone number) and its own way of delivering a message, and each is fully encapsulated in the sense of the previous chapter. What is new is that both now share a public type, Notifier, that neither of them owns.

plantuml Diagram
Structural Typing

TypeScript checks types by shape, not by name: a value is acceptable wherever a type is expected if it has the required members, whatever it was declared as. A class with a matching send method is therefore usable as a Notifier even without writing implements Notifier. Why write implements, then? Because it declares intent and turns a silent mismatch into a clear error: with implements Notifier, forgetting send or misspelling it fails at the class, where the mistake is, rather than later at some distant call site. Some languages, such as Java, are instead nominal: a class is a Notifier only if it explicitly says so. In TypeScript, implements is a checked declaration of intent layered on top of structural typing, not the thing that makes the class assignable.

Apparent and Actual Types

Once a class implements an interface, an object of that class can be held in a variable declared with the interface type:

typescript
const alerts: Notifier = new EmailNotifier("ops@example.com");

Two different types are in play here. The apparent type is the one written in the code, Notifier: it is what the compiler knows about the variable. The actual type is the type of the object that exists at run time, EmailNotifier. This is the static and dynamic distinction from Part 1 seen from a new angle: the apparent type belongs to the static view the compiler checks, and the actual type belongs to the dynamic view that exists only once the program runs.

The apparent type decides what you are allowed to do with the variable. Through an apparent type of Notifier you may call send, because the contract promises it, and nothing more:

typescript
alerts.send("disk almost full"); // allowed: send is declared on Notifier
// alerts.address                // rejected: address is not part of Notifier

That restriction looks like a loss, but it is exactly what we want: the code relies only on what Notifier promises, so the object behind alerts can be of any type that implements Notifier, and every line still type-checks. Declaring the variable with the interface, rather than with EmailNotifier, is the difference between code that works with one class and code that works with all of them. This is the practice usually summarised as program to an interface, not an implementation: prefer the apparent type that names the contract over the one that names a specific class, for parameters, fields, and return types alike.

One Boundary, Many Implementations

The benefit appears as soon as code is written against the interface. A function that raises an alert takes a Notifier, or a list of them, and never mentions a concrete channel:

typescript
function alertAll(channels: Notifier[], message: string): void {
    for (const channel of channels) {
        channel.send(message);
    }
}

alertAll works for an EmailNotifier, for an SmsNotifier, for a list mixing the two, and for any channel written in the future, with no change to its body. The interface is a boundary, with the alerting logic on one side and the delivery mechanisms on the other; each side can be read, changed, and tested with only the contract in view, never the other side's code.

Notice what alertAll cannot do: it cannot tell, and cannot act on, which kind of channel each element is. Every element is, as far as the code can see, simply a Notifier. In this chapter that uniformity is the goal.

Testing Across the Boundary

A boundary that callers depend on is also a boundary that tests can exploit. The real channels have effects we do not want in a test suite: a test of alertAll should not send actual email or actual text messages. Because alertAll depends only on Notifier, a test can hand it a stand-in that records what it was asked to send instead of sending anything:

typescript
class RecordingNotifier implements Notifier {
    public readonly sent: string[] = [];

    send(message: string): void {
        this.sent.push(message);
    }
}

test("alertAll delivers the message over every channel", () => {
    const a = new RecordingNotifier();
    const b = new RecordingNotifier();

    alertAll([a, b], "deploy finished");

    expect(a.sent).to.deep.equal(["deploy finished"]);
    expect(b.sent).to.deep.equal(["deploy finished"]);
});

RecordingNotifier is a third implementation of Notifier, written only for tests. A stand-in like this is called a test double, or a mock object: it satisfies the same contract as the real thing but is simpler and observable, so the code under test can be exercised in isolation. This is the black-box testing of the verification chapter, now made easy by an interface: the test depends on the contract, the code under test depends on the contract, and the real delivery mechanism is simply not present. Designing against interfaces is, among other things, what makes code testable.

plantuml Diagram

Keeping Interfaces Small

Notifier declares one method, and that restraint is itself a design choice. Suppose some channels can also report whether a message was acknowledged by the provider. It is tempting to add that to Notifier, but doing so would force every implementation, including ones that can confirm nothing, to provide the operation. The capability belongs in its own small interface:

typescript
interface Confirmable {
    /** Returns true once the provider has acknowledged the last send. */
    wasDelivered(): boolean;
}

A class can implement more than one interface by listing them, so a channel that can confirm delivery commits to both contracts:

typescript
class SmsNotifier implements Notifier, Confirmable {
    // send is unchanged from above

    public wasDelivered(): boolean {
        // report the provider's acknowledgement
    }
}

Now each caller depends on exactly the contract it needs: code that only sends takes a Notifier, and code that also checks receipts takes a Confirmable. A channel that cannot confirm anything remains a plain Notifier and is never forced to fake an operation it does not support.

plantuml Diagram

Keeping interfaces small in this way is the interface-level equivalent of the advice for cohesion: classes should have a single responsibility. A small, focused interface describes one capability; a large interface that bundles several forces implementers to support operations that have nothing to do with one another, and forces callers to depend on more than they use. This guidance, that it is better to have many small interfaces than one large one, is known as the interface segregation principle. Small interfaces also preserve implementation freedom: every operation a contract promises becomes behaviour callers may rely on, so a large interface leaks more implementation detail into the public surface, while a small one keeps more of each implementation unobservable, and therefore free to change.

Constraining a Type Parameter

Interfaces have a second use, and it resolves a problem the previous chapter left open. Roster<T> could not compare two of its members, because a class that knows nothing about T has no operation to compare them with, so we handed it a comparison function when it was built. An interface offers the other route: rather than passing the operation in, require that every member already provides it.

typescript
interface Identifiable {
    /**
     * Reports whether this object denotes the same thing as another.
     *
     * @param {Identifiable} other the object to compare against
     * @returns {boolean} true when both denote the same thing
     */
    sameAs(other: Identifiable): boolean;
}

A type parameter can then be restricted to types that satisfy that interface, written with extends:

typescript
class <Name><T extends <Interface>> {
    // every T is guaranteed to provide the interface's operations
}

The restriction is what gives the class something to call:

typescript
class Roster<T extends Identifiable> {
    private readonly members: T[];

    isMember(candidate: T): boolean {
        for (const member of this.members) {
            if (member.sameAs(candidate)) {
                return true;
            }
        }
        return false;
    }
}

member.sameAs(candidate) compiles because the constraint promises that every member has the method, whatever type it turns out to be. This is the same reasoning as an apparent type, one level up: there the compiler knew a variable's operations from the type written on it, and here it knows a type parameter's operations from the constraint written on it.

The two routes trade against each other. Constraining buys the class knowledge and pays for it in reach: Roster<Seat> works only if Seat implements Identifiable, and Roster<string> no longer compiles at all, because a string has no sameAs method. Passing a function in keeps the class usable with every type, including ones you did not write and primitives you cannot change, but obliges every caller to supply the comparison. Constrain when the operation is part of what the members fundamentally are and you control those types; pass the operation in when the members are types you do not own, or when different callers need different notions of sameness.

Whichever you choose, the constraint should name the smallest interface that supplies what the class needs, for the reason the previous section gave: a constraint is a demand made of every type that wants to be a member, and a demand for operations the class never calls narrows the class for nothing.

Depending on the Contract

An interface is the public surface of a class extracted into a named type that any class can implement and any caller can depend on. Depending on the interface rather than on a concrete class is the strongest form of information hiding. The previous two chapters let a class change its representation without disturbing callers; an interface lets the entire class behind the contract change without disturbing them. The cost is small and the discipline is simple: name contracts as interfaces, keep them small and well documented, and write the rest of the program against them.

Now we have a boundary in place, with one contract and several classes implementing it. The next chapter asks what happens when those classes are not merely interchangeable but embody different behaviour, so that one call does different work depending on the object behind the interface. That is polymorphism, and it is what makes interfaces more than a tidy way to organise types.

Exercise: Input Validation Rules

As a developer building a registration form, I want to run each field through a set of independent rules, so that every violated constraint is reported to the user rather than only the first one found.

Here is the interface and a function that collects the descriptions of all failed rules:

typescript
interface Validator {
    /** Returns true if the input satisfies this rule. */
    check(input: string): boolean;
    /** A short description of what this rule requires, suitable for an error message. */
    rule(): string;
}

/**
 * Returns the description of every rule that input fails.
 * Returns an empty array if all rules are satisfied.
 *
 * @param {Validator[]} validators the rules to apply, in order
 * @param {string} input the value to check
 * @returns {string[]} descriptions of every violated rule
 */
function failedRules(validators: Validator[], input: string): string[] {
    /* ... */
}
plantuml Diagram

Work through the following:

  1. Implementing failedRules. The Validator contract says check returns true for a passing input, and rule returns the violation message. Write the body of failedRules so that it collects the rule() of every validator whose check returns false.
  2. Writing validators. Implement two classes that satisfy Validator: a MinLengthValidator that fails when the input is shorter than a configurable minimum, and a NoSpacesValidator that fails when the input contains a space. Neither should require any change to failedRules.
  3. Testability. Write a RecordingValidator that records every input passed to check and always returns true. Use it to confirm that failedRules calls check on every validator, even after an earlier one has failed.
  4. Interface size. Suppose you need validators to carry a severity so that callers can display warnings differently from hard errors. What are the costs of adding a severity(): string method to Validator, compared with defining a separate SeverityRated interface that only some validators implement?