TypeScript Learning Adventures: A Tale of Love and Hate - Interfaces

An interface is a way to describe the structure of an object, indicating what it should contain. To illustrate this, let's take the example of creating an interface using the interface keyword in TypeScript, which is not available in plain JavaScript.

TypeScript Learning Adventures: A Tale of Love and Hate - Interfaces
Photo by Mike Bergmann / Unsplash

An interface is a way to describe the structure of an object, indicating what it should contain.

To illustrate this, let's take the example of creating an interface using the interface keyword in TypeScript, which is not available in plain JavaScript.

We'll use the name Fish for our interface, and like a class, we'll start with a capital letter, although this isn't necessary.

The Fish interface allows us to specify what properties an object must have to be considered a Fish, without using it as a blueprint.

For example, we might define a Fish to have:

  • a name (a string)
  • a type (which could be oceanic, coastal, shallow, etc.) also of type string.
interface Fish {
  name: string;
  type: string;
}

In this example, we're defining properties or fields by specifying their names and the types of values they'll store.

However, we're not assigning any specific values to these properties. If we were to attempt to assign default values, we would encounter an error because interfaces cannot have initializers.

interface Fish {
  name: string = 'Sailfish'; 
}

// throws error 'An interface property cannot have an initializer'

In an interface, we can describe the framework of an object, but not its specific values.

Additionally, we can specify a method, such as time that returns when this fish can be caught (day, night, or both). However, we only describe the method's structure or definition, not its actual implementation.

To define a method that a Fish object should have, we add the method's name followed by parentheses and the return type. We may also include any arguments in parentheses, if necessary.

interface Fish {
  name: string;
  type: string;
  
  time(): string;
}

Idea behind interfaces

What's the purpose of the Fish interface and how can we use it?

One way is to utilize it for type-checking an object.

For instance, we can create a variable named sailfish but we might not initialize it immediately. Instead, we might want to eventually store an object in it that follows the structure defined by our Fish interface.

Therefore, we can assign the type Fish to sailfish, using our interface as a type. When we later assign a value to sailfish, it must be an object that conforms to the structure described by the interface.

This means the object must have:

  • name property holding a string,
  • type property holding a string,
  • and a time method that returns a string and takes no parameters, as interfaces are used to define objects.

Here is an example:

interface Fish {
  name: string;
  type: string;

  time(): string;
}

let sailfish: Fish;

sailfish = {
  name: "Sailfish",
  type: "Oceanic",
  time() {
    return `${this.name} can be caught at Day`;
  },
};

console.log(sailfish.time());
// prints "Sailfish can be caught at Day"

If we try to assign sailfish without type, for example, we get an error:

sailfish = {
  name: "Sailfish",
  time() {
    return `${this.name} can be caught at Day`;
  },
};
// property 'type' is missing in type { name: string; type: string; time(): string; } but required in Fish
Photo by Michael Worden / Unsplash

Interface vs Type

Why do we require interfaces?

Wouldn't it be the same if we added a custom type here and there?

The only distinction is that we have to add an equal sign to indicate that the Fish type is an object that has a specific structure:

type Fish = {
  name: string;
  type: string;

  time(): string;
}

let sailfish: Fish;

sailfish = {
  name: "Sailfish",
  type: "Oceanic",
  time() {
    return `${this.name} can be caught at Day`;
  },
};

console.log(sailfish.time());
// prints "Sailfish can be caught at Day"

After saving the changes, the code compiles successfully without any errors.

Therefore, we could replace our interface with this custom type, and it would function as previously.

However, why do we need an interface?

Although interfaces and custom types can often be used interchangeably, they are not exactly the same, and there are some differences between them.

For example, interfaces can only describe the structure of an object, while with types, you can also store other items such as union types, which are explained in a previous article.

The types seem to offer more flexibility, but interfaces are clearer in their purpose.

When you define something as an interface, it's apparent that you want to define the structure of an object. This is why you often see interfaces used for defining object types.

One advantage of interfaces over custom types is that you can implement an interface in a class.

An interface can be used as a contract that a class must adhere to.

For example, an interface named WithHuntingTime can be used to indicate that any Fish object that should be treated as WithHuntingTime must have a time method:

interface WithHuntingTime {
  time(): string;
}

class Fish implements WithHuntingTime {
  name: string;
  type: string;

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

  time() {
    return `${this.name} can be caught at Day`;
  }
}

let sailfish: WithHuntingTime;
sailfish = new Fish("Sailfish", "Oceanic");
console.log(sailfish.time());
// prints "Sailfish can be caught at Day" 

This interface can be used as a standard for multiple classes so that every class that implements this interface must have a time method to comply with it.

If I forget to add properties from the interface in the class, I will get an error:

Class 'Fish' incorrectly implements interface 'WithHuntingTime'. Property 'time' is missing in type 'Fish' but required in type 'WithHuntingTime'.

In Typescript, the class is expected to comply with the contract set by the interface.

To achieve this, we use the implements keyword followed by the name of the interface (e.g., WithHuntingTime) after the class name.

Unlike inheritance, a class can implement more than one interface, and this is achieved by separating the interfaces with a comma:

interface WithHuntingTime {
  time(): string;
}

interface WithName {
  name: string;
}

class Fish implements WithHuntingTime, WithName {
  name: string;
  type: string;

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

  time() {
    return `${this.name} can be caught at Day`;
  }
}

let sailfish: WithHuntingTime;
sailfish = new Fish("Sailfish", "Oceanic");
console.log(sailfish.time());
// prints "Sailfish can be caught at Day" 

In our class, we have the freedom to add more fields and methods, and also extend it, just like any other class.

However, we must implement the time method correctly and have the name property because we're implementing these interfaces.

Therefore, interfaces are often used to share functionality among different classes, without concerning their concrete implementation. You can't have implementation or values inside interfaces, but they are used to define the structure and features a class should have.

It's similar to working with abstract classes, except that an interface has no implementation details, while abstract classes can have a mix of concrete implementation and parts that need to be overwritten.

This is an essential difference between interfaces and abstract classes.

We could have set the type to Fish, but using the type WithHuntingTime works as well, as the Fish object we store in the sailfishis based on the WithHuntingTime interface.

In summary, interfaces provide many powerful features.

Nemo
Photo by Rachel Hisko / Unsplash

In situations where we want a specific set of functionalities, such as a time method that a class must have, interfaces are useful.

They allow us to share functionality among classes, ensuring that each class implements its own implementation of the method.

Interfaces help us enforce a certain structure without relying on other parts of our code.

By setting sailfish as of type WithHuntingTime, we don't need to know everything about the object or class, but we do know that it must have a time method.

This allows us to write flexible and powerful code.

Readonly properties

In an interface, the only modifier you can add to a property is readonly. You cannot use public or private modifiers in an interface.

The readonly modifier makes it clear that property in an object built based on this interface can only be set once and is readonly thereafter.

This ensures that the property cannot be changed after the object has been initialized.

Although you can also use the readonly modifier in a type, it is more common to use interfaces when working with objects, and readonly is just an additional feature you can add to an interface.

interface WithName {
  readonly name: string;
  time(): string;
}

class Fish implements WithName {
  name: string;
  type: string;

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

  time() {
    return `${this.name} can be caught at Day`;
  }
}

let sailfish: WithName;
sailfish = new Fish("Sailfish", "Oceanic");
console.log(sailfish.time());
// prints "Sailfish can be caught at Day"

With readonly added here (btw, you see I didn't add it here in the class) if I now go to use one and then try to set the name to something else, I get an error here already because it's readonly:

let sailfish: WithName;
sailfish = new Fish("Sailfish", "Oceanic");
sailfish.name = 'Shark'
// throws an error: Can not assign to 'name' because it is a readonly property.

Even though we didn't explicitly specify the readonly modifier when implementing the WithName interface in our class, the interface still has an effect.

The class recognizes that it implements WithName and automatically assumes that the name property must be readonly because that's what we defined in the interface.

This is very convenient.

Interface extension

In interfaces, you can also use inheritance.

For instance, if we have an interface named WithFishInfo that ensures the existence of a time method, we can make sure that it extends the WithName interface.

By doing this, a new interface is created that enforces every object based on WithFishInfo to have not only a time method but also a name and type, which are defined in WithName.

To achieve this, we can use the extends keyword on the interface and specify WithName as the interface is to be extended.

Thus, this creates a new interface that combines the properties and methods of both the WithFishInfo and WithName interfaces:

interface WithName {
  readonly name: string;
  type: string;
}

interface WithFishInfo extends WithName {
  time(): string;
}

class Fish implements WithName {
  name: string;
  type: string;

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

  time() {
    return `${this.name} can be caught at Night`;
  }
}

let arrowSquid: WithFishInfo;
arrowSquid = new Fish("Arrow Squid", "Coastal");
console.log(arrowSquid.time());
// prints "Arrow Squid can be caught at Night"

This is how we can combine interfaces.

However, why would we want to split an interface like this?

Well, let's say we have an application where some objects should only be required to have a name and not a time method. On the other hand, some objects need both.

With this split, we can achieve this. Some classes can implement WithName, and others can implement WithFishInfo and are required to have both time method and a name.

We can extend interfaces, and we can extend more than one at a time, which means we can merge multiple interfaces into a single one, separated by a comma.

It is worth noting that this is not possible for classes.

Photo by Natalia Y. / Unsplash

Interfaces as function types

Previously, I mentioned that interfaces are primarily used to define the structure of an object.

However, interfaces can also be utilized to define the structure of a function. This can replace the function types that were previously defined using the type keyword.

As a quick reminder, a custom type can be defined for a function by specifying its type.

type TakeBait = (tool: string) => boolean;

let takeBait: TakeBait;
takeBait = (tool: string) => {
  return tool === "rod";
  // catch this fish only with rod 
};

Interfaces, as explained earlier, are used to define the structure of an object.

However, in JavaScript, functions are also objects.

Therefore, you can use interfaces to create function types as well. This is an exception because functions are not usually thought of as objects.

interface TakeBait {
  (tool: string): boolean;
}

let takeBait: TakeBait;
takeBait = (tool: string) => {
  return tool === "rod";
};

In summary, instead of defining a method with a name, we define an anonymous function within the interface.

TypeScript recognizes this special syntax and understands that we want to use this interface as a function type, dictating how the function should look like.

This allows us to use the interface as a function type, such as the TakeBait interface shown.

If we were to try and accept a number instead of a string, we would get an error since it is not assignable:

interface TakeBait {
  (tool: string): boolean;
}

let takeBait: TakeBait;
takeBait = (tool: number) => {
  return tool === "rod";
};

// Type '(tool: number) => bool' is not assignable to type 'TakeBait'.
// Types of parameters 'tool' and 'tool' are incompatible.
// Type 'string' is not assignable to type 'number'.

So it's simply an alternative to this custom type.

While using a custom type to define a function is more common and shorter, using an interface as an alternative syntax to define a function is also possible.

This can be useful to know in case you encounter it in a project and are unfamiliar with the syntax.

The anonymous function defined in the interface is understood by TypeScript as the shape of the function that should be used, and it can be used in place of a custom function type.

Optional properties and parameters

Interfaces and classes can have optional properties defined by adding a question mark after the property name.

For example, if we want to add an optional property called secondaryName to the WithFishInfo interface, we can do so by declaring it with a question mark after the property name.

This property can be of type string and is not mandatory for every class implementing the WithFishInfo interface:

interface WithName {
  readonly name: string;
  secondaryName?: string;
  type: string;
}

interface WithFishInfo extends WithName {
  time(): string;
}

class Fish implements WithFishInfo {
  name: string;
  type: string;

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

  time() {
    return `${this.name} can be caught at Night`;
  }
}

let arrowSquid: WithFishInfo;
arrowSquid = new Fish("Arrow Squid", "Coastal");
console.log(arrowSquid.time());
// prints "Arrow Squid can be caught at Night"

As you can see, it works fine, even though we didn't implement secondaryName property.

To make parameters optional, we can provide a default value in the constructor or add a question mark to the parameter name, which makes the default value undefined.

If we want to allow a property to be optional, we can add a question mark after the property secondaryName in both the interface and the class. In the class, we also need to provide a default value or add a question mark to the constructor parameter.

In the time method, we check if we have a secondaryName property, and if we do, we log it along with the name. If we don't have a secondaryName, we only log the name.

interface WithName {
  readonly name: string;
  secondaryName?: string;
  type: string;
}

interface WithFishInfo extends WithName {
  time(): string;
}

class Fish implements WithName {
  name: string;
  secondaryName?: string;
  type: string;

  constructor(name: string, type: string, secondaryName?: string) {
    this.name = name;
    this.secondaryName = secondaryName;
    this.type = type;
  }

  time() {
    if (this.secondaryName) {
      return `${this.name}/${this.secondaryName} can be caught at Night`;
    }
    return `${this.name} can be caught at Night`;
  }
}

let arrowSquid: WithFishInfo;
arrowSquid = new Fish("Arrow Squid", "Coastal", "Calamari");
console.log(arrowSquid.time());
// prints "Arrow Squid/Calamari can be caught at Night"

These optional properties and parameters provide more flexibility in defining the structure of our classes and adhering to the contracts defined in our interfaces.

That's a wrap on interfaces!

I hope you found this information useful and informative. Keep an eye out for our upcoming posts, as we continue to explore exciting topics in TypeScript ☀️