Execute Program

Everyday TypeScript: Implementing Types

Welcome to the Implementing Types lesson!

This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!

  • We saw the extends keyword in an earlier lesson. When a class extends another class, it gets all of the other class's properties. Likewise, when an interface extends an interface, it gets all of the other interface's properties.

  • >
    class Animal {
    legs: number;

    constructor(legs: number) {
    this.legs = legs;
    }
    }

    class Cat extends Animal {
    name: string;

    constructor(name: string) {
    super(4);
    this.name = name;
    }
    }

    const msFluff = new Cat('Ms. Fluff');

    // Cats have `legs`, which comes from their parent class `Animal`.
    [msFluff.legs, msFluff.name];
    Result:
  • When we say class Cat extends Animal, we're asking for two different things:

    1. Any Cat should be narrowable to an Animal. For example, we should be able to pass a Cat to a function that wants an Animal.
    2. Cat automatically gets all of Animal's properties (its fields and methods). In our example above, Cat gets the legs property, as well as Animal's constructor function that Cat calls via super(). The child class doesn't have to define any of those properties itself.
  • When writing classes, sometimes we want (1) but not (2). We want to ensure that the class conforms to an interface, but we don't want it to automatically inherit any property or method definitions.

  • For example, we might have a HasName interface defined as {name: string}. We want our User class to have a name property, making it compatible with the HasName interface, and we want TypeScript to check that the two are actually compatible. But we don't want our class to inherit any properties from HasName.

  • TypeScript's implements keyword does exactly that. When a class implements an interface, the class must have all of the interface's properties, and their types must be compatible with the implemented interface's types. But we're responsible for defining those properties ourselves.

  • At first, it may seem like that's creating extra work for no reason. Why use implements, which makes us define those properties even though they're already defined in the interface that we're implementing? If we used extends, we wouldn't have to redefine them at all.

  • The primary reason is that a class can extend at most one other type, but it can implement many different types at the same time. In any situation where we want a class to conform to multiple types simultaneously, we have to use implements.

  • Angular is a real-world use case for implementing multiple interfaces. When an Angular component responds to multiple "lifecycle hooks", it will implement multiple interfaces, with each interface corresponding to one lifecycle hook. For example, a component defined with class MyComponent implements OnInit, OnChanges will have one lifecycle hook for when the component is initialized, and another hook for when data changes. We'll see more classes that implement multiple interfaces in a future lesson on single vs. multiple inheritance.

  • Here's a code problem:

    The class below implements HasName, but it doesn't actually declare the name property in HasName. That causes a type error.

    Fix the class by adding a declaration for the name field. You'll need to add that declaration line to the class's body, but you won't need to change any of the existing lines.

    interface HasName {
    name: string
    }
    class User implements HasName {
    name: string;
    age: number;

    constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
    }
    }
    const amir = new User('Amir', 36);
    amir.name;
    Goal:
    'Amir'
    Yours:
    'Amir'Pass Icon
  • Our class above implemented an interface. TypeScript classes can also implement regular object types.

  • >
    type HasName = {
    name: string
    };

    class User implements HasName {
    name: string;

    constructor(name: string) {
    this.name = name;
    }
    }

    const amir = new User('Amir');
  • If User implements HasName, we know that User's properties are compatible with HasName's. But if you read that sentence carefully, you'll see that it's also the definition of type narrowing! We can narrow a User to a HasName if it has all of HasName's properties, and those properties have compatible types.

  • This shows us why implements is so important: it lets us say "this type should be narrowable to that other type", and TypeScript will check that for us.

  • When using implements, all of the narrowing rules still apply. If we narrow a User to a HasName, then we can only access the properties that were defined on HasName. Trying to access other User properties is a type error, even though those properties may exist at runtime.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const hasName: HasName = amir;
    hasName.name;
    Result:
    'Amir'Pass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const hasName: HasName = amir;
    hasName.age;
    Result:
    type error: Property 'age' does not exist on type 'HasName'.Pass Icon
  • When a class implements an interface, the two must agree on the properties' types. Otherwise, we get a type error.

  • >
    interface HasName {
    name: string
    }

    class User implements HasName {
    name: number;
    }
    Result:
    type error: Property 'name' in type 'User' is not assignable to the same property in base type 'HasName'.
      Type 'number' is not assignable to type 'string'.Pass Icon
  • Take note of that error message. It's very specific, telling us which type is the base type and which property caused the problem. This is one of the benefits of implements: it results in better error messages. We'll see more about that later in this lesson.

  • Only classes can implement other types. We can't say interface User implements HasName, for example; that's a type error. (It should probably be called a syntax error, but the TypeScript compiler calls it a type error instead.)

  • >
    interface HasName {
    name: string
    }
    interface User implements HasName {
    name: string
    }
    Result:
  • Let's revisit our User and HasName example one more time. The example below contains code from the earlier example with User implements HasName. This time, we've removed the implements HasName. The code still works!

  • >
    type HasName = {
    name: string
    };

    class User {
    name: string;

    constructor(name: string) {
    this.name = name;
    }
    }

    const amir = new User('Amir');

    const hasName: HasName = amir;
    hasName.name;
    Result:
    'Amir'Pass Icon
  • This works because TypeScript's structural type system allows type narrowing based on type compatibility alone. If User has all of the properties of HasName then we can narrow a User to a HasName. Adding implements HasName is never strictly necessary.

  • The next example is the same as the one above, but we've added a mistake: name has the wrong type. Now we get a type error when we try to narrow a User to a HasName.

  • >
    type HasName = {
    name: string
    };

    class User {
    // Note that the `name` property has the wrong type!
    name: string[];

    constructor(name: string) {
    this.name = [name];
    }
    }

    const amir = new User('Amir');

    const hasName: HasName = amir;
    hasName.name;
    Result:
    type error: Type 'User' is not assignable to type 'HasName'.
      Types of property 'name' are incompatible.
        Type 'string[]' is not assignable to type 'string'.Pass Icon
  • Given that TypeScript can narrow based only on properties, you might wonder: "why use implements at all?" The example above shows us one important reason. Here, our type error comes from code that uses the User class. But in our earlier example with implements, the type error was on the User class itself. It's better to have a type error on the class, because the class is where the bug exists. The types are enforced either way, but adding an implements allows TypeScript to show us the exact location of the bug.

  • This is a good chance to step back and remind ourselves of why TypeScript exists:

    • We tell the compiler what we believe about our code. ("This is a string", "that's an Array<number>", "this is a User | Organization", etc.)
    • The compiler analyzes all of those beliefs at once, more carefully than any human can. It tells us when some of our beliefs contradict others. Those contradictions are type errors.
    • If we can state our beliefs in a more precise way, we get more precise error messages in response. That makes debugging easier.
  • If we tell the compiler "we believe User implements HasName", it can check that belief directly. It doesn't have to wait for us to make a mistake by assigning a User to a HasName.