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.
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
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 sailfish
is based on the WithHuntingTime
interface.
In summary, interfaces provide many powerful features.
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.
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 ☀️
Comments ()