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
extendskeyword 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:- Any
Catshould be narrowable to anAnimal. For example, we should be able to pass aCatto a function that wants anAnimal. Catautomatically gets all ofAnimal's properties (its fields and methods). In our example above,Catgets thelegsproperty, as well asAnimal's constructor function thatCatcalls viasuper(). The child class doesn't have to define any of those properties itself.
- Any
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
HasNameinterface defined as{name: string}. We want ourUserclass to have anameproperty, making it compatible with theHasNameinterface, and we want TypeScript to check that the two are actually compatible. But we don't want our class to inherit any properties fromHasName.TypeScript's
implementskeyword does exactly that. When a classimplementsan 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 usedextends, 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, OnChangeswill 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 thenameproperty inHasName. That causes a type error.Fix the class by adding a declaration for the
namefield. 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'
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 thatUser's properties are compatible withHasName's. But if you read that sentence carefully, you'll see that it's also the definition of type narrowing! We can narrow aUserto aHasNameif it has all ofHasName's properties, and those properties have compatible types.This shows us why
implementsis 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 aUserto aHasName, then we can only access the properties that were defined onHasName. Trying to access otherUserproperties 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'
- 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'.
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'.
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
UserandHasNameexample one more time. The example below contains code from the earlier example withUser implements HasName. This time, we've removed theimplements 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'
This works because TypeScript's structural type system allows type narrowing based on type compatibility alone. If
Userhas all of the properties ofHasNamethen we can narrow aUserto aHasName. Addingimplements HasNameis never strictly necessary.The next example is the same as the one above, but we've added a mistake:
namehas the wrong type. Now we get a type error when we try to narrow aUserto aHasName.>
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'.Given that TypeScript can narrow based only on properties, you might wonder: "why use
implementsat all?" The example above shows us one important reason. Here, our type error comes from code that uses theUserclass. But in our earlier example withimplements, the type error was on theUserclass 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 animplementsallows 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 anArray<number>", "this is aUser | 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.
- We tell the compiler what we believe about our code.
("This is a
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 aUserto aHasName.