When working with TypeScript, there are several core principles you’ll use a lot. One of those is interfaces, so it pays to have a solid understanding and grasp of them. In this post, we’re going to do a deep dive into interfaces, their benefits as well as their various use cases.
If you’d like to follow along with this and experiment with using interfaces yourself, you can use the TypeScript Playground here.
What are interfaces?
Let’s start by looking at what exactly interfaces are. Interfaces are a feature of TypeScript that allows us to define the structure or shape of an object and specify the properties and methods that an object has or should have. Their primary function is type checking and aiding developers in catching type-related errors during development.
Here, you can see a small example of how we can define an interface and apply it to an object.
interface Person {
name: string;
age: number;
sex: "male" | "female";
}
const personOne: Person = {
name: "Coner",
age: 24,
sex: "male",
}
console.log(personOne.name); // Coner
// 👇 Property 'hobbies' does not exist on type 'Person'
console.log(personOne.hobbies); // undefined
As you can see in the above code block, we access a property that is defined in the interface with no issues by running console.log(personOne.name)
.
We also can see an example of us trying to access a property that doesn’t exist in the interface by running console.log(personOne.hobbies)
, therefore throwing a type error.
Benefits of interfaces in TypeScript
Now that we understand a bit more about interfaces, what they look like, and how to use them, let’s take a closer look at their benefits to us.
Type checking
The first benefit of interfaces is the most obvious one: they highlight any possible type errors and issues in our code to prevent us from accessing any properties that might not exist. This, in turn, helps us reduce runtime errors and prevent bugs from being created.
Contract definition
Another benefit of interfaces is that they define and create clear contracts for the functions and code that consume them. They prevent us from consuming methods and properties that don’t exist and help ensure we stay within the established structure defined for the object that the interface is describing.
Documentation and readability
Because interfaces define the properties and methods that exist on an object as well as their types, they act as a form of documentation that enhances the code readability and helps developers reading the code understand how it works and how the code fits together.
Reusability
Since interfaces can always be extended and reused in various places, they promote code reusability and help reduce duplication. By defining central, common interfaces that can be reused and extended throughout an application, you can ensure consistency in your code and logic.
Code navigation and autocompletion
IDEs that integrate with TypeScript can read the interfaces you define and offer autocompletion suggestions from them, as well as help with code navigation to make you a more productive and efficient developer.
Easier refactoring
Finally, interfaces help make refactoring easier because you’re able to update the implementation of a piece of code or logic, and as long as it adheres to the same interface, other code that depends on the changed logic shouldn’t be impacted.
First time here? Discover what Prismic can do!
👋 Meet Prismic, your solution for creating performant websites! Developers, build with your preferred tech stack and deliver a visual page builder to marketers so they can quickly create on-brand pages independently!
Using interfaces in TypeScript
I hope, at this point, you’re convinced of the benefits of interfaces and why we should be using them. So, now, let’s look at how we can use them in our TypeScript code.
Function types
In addition to defining the types of objects, we can also use interfaces to type functions, their return values, and their arguments. For example, we can do something like this.
interface Args {
name: string;
age: number;
}
interface Return {
name: string;
age: number;
doubledAge: number
}
function ageDoubler({name, age}: Args): Return {
return {
name,
age,
doubledAge: age * 2,
}
}
Classes
TypeScript has native support for the class
keyword that was implemented in ES2015. You can define a class as well as its fields and methods like this.
class Person {
name: string = '';
age: number = 0;
}
const me = new Person();
But, we can combine these class definitions with interfaces to make sure the class correctly implements all of the properties defined on the interface like so.
interface PersonInt {
name: string;
age: number;
}
// Class 'Person' incorrectly implements interface 'PersonInt'.
// Property 'age' is missing in type 'Person' but required in type 'PersonInt'
class Person implements PersonInt {
name: string = '';
}
const me = new Person();
In this example, we have an interface called PersonInt
and use the implements
keyword to say the class Person
will have all of the types defined in PersonInt
. Because this isn’t true and the age
field is missing in the class, an error is thrown.
Optional properties
When working with objects in TypeScript, it’s quite common to have properties that might only be defined some of the time. In these instances, we can define optional properties like so.
interface Person {
name: string;
age: number;
// 👇 Note the ?: makes the property optional
color?: string;
}
Now, when we consume the color
property on an object typed with the Person
interface, we’ll have to account for the fact that it might not be present (it’ll be undefined
if not defined).
readonly properties
In TypeScript, we can use the readonly
keyword with interfaces to mark a property as readonly
. This means that the target property can’t be written to during type-checking although its behavior doesn’t change during runtime.
interface Person {
readonly name: string;
}
const person: Person = {
name: 'Coner',
}
function updateName(person: Person) {
// We can read from 'person.name'.
console.log(`name has the value '${person.name}'.`); // "name has the value 'Coner'."
// But we can't re-assign it.
// 👇 Cannot assign to 'name' because it is a read-only property.
person.name = "hello";
}
Index signatures
There might be a time when you know the shape of your object, but you don’t know the actual properties of it. Or, the properties might change, but the shape will remain consistent. In these situations, it’s not practical or potentially possible to type every single property on the interface. To to get around this, we can use index signatures.
interface Index {
[key: string]: boolean
}
What this interface says is if we index an object that is typed using the Index
interface with a string
, we’ll have a boolean
returned to us. You’re not limited to just boolean
types, either. It could also be another type or interface if you wish, which is great for times when you don’t know all of the properties but know their shape.
Also, if you want to combine index signatures and normal interface definitions, you can do so. However, if you do this, the index signature needs to be updated to contain all of the potential return types.
interface Index {
one: string;
two: number;
[key: string]: string | number | boolean
}
It’s worth noting that while index signatures can make your life easier, where possible and feasible, you should always reach for actually typing properties on an object as that’ll give you better type safety.
Extending interfaces
Sometimes, you want to extend an existing interface and add new fields to it without changing the original one. This can be achieved by using the extends
keyword. This allows you to take an existing interface and create a copy of it while also adding new fields to it. For example, we could do something like this.
interface Person {
name: string;
age: string;
}
interface PersonWithHobbies extends Person {
hobbies: string[];
}
/*
👇 PersonWithHobbies would be:
interface PersonWithHobbies {
name: string;
age: string;
hobbies: string[];
}
*/
In this example, we took the original Person
interface and extended it with the hobbies
property to create a new interface called PersonWithHobbies
. So, at this point, we have two interfaces, Person
and PersonWithHobbies
, with them being identical apart from the latter having the hobbies
property added to it.
If you want to, you can also combine multiple existing interfaces to create a new one without adding any new properties to it, which can be done like so.
interface Person {
name: string;
age: string;
}
interface Hobbies {
hobbies: string[];
}
interface PersonWithHobbies extends Person, Hobbies {}
Discriminating unions
Discriminating unions are a way we can define a new type from multiple interfaces and use a common property present on all of the interfaces (the “discriminator”) to distinguish between the types in our logic.
For example, we can define two interfaces for two different shapes (Circle
and Square
), and we can then use a property present on both of them (kind
) to dictate which interface we are dealing with at that moment.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
if (shape.kind === "circle") {
// We know it's a Circle interface here
return Math.PI * shape.radius ** 2;
}
// We know it's a Square interface here
return shape.sideLength ** 2;
}
const circle: Shape = { kind: "circle", radius: 5 };
const square: Shape = { kind: "square", sideLength: 4 };
console.log(getArea(circle)); // Output: 78.53981633974483
console.log(getArea(square)); // Output: 16
Generic interfaces
Finally, we have Generic Interfaces, which allow us to combine the power of generics with interfaces to create interfaces that consume generics and assign the generic type to one of or multiple properties defined in the interface.
interface Item<T> {
value: T;
}
const stringItem: Item<string> = { value: 'Item' };
const numberItem: Item<number> = { value: 22 };
Closing thoughts on TypeScript interfaces
Throughout this post, we’ve looked at TypeScript interfaces, what they are, their benefits, and how to use them in your code. If you’d like to dig deeper into any of the topics we’ve covered in this post, then you can use the TypeScript documentation here, or if you have any questions, feel free to drop a comment below! 👇