Editor’s note: This article was last updated on 19 September 2023 to add information about inheritance in TypeScript interfaces, including multiple inheritance and overriding properties in inherited interfaces.
Interfaces are one of TypeScript’s core features, allowing developers to flexibly and expressively enforce constraints on their code to reduce bugs and improve code readability. Let’s explore the various interface characteristics and how we might better leverage them in our programs.
Jump ahead:
Interfaces allow developers to name a type for later reference in their programs. For example, a public library’s management software might have a Book
interface for data representing books in the library’s collection:
interface Book { title: string; author: string; isbn: string; }
With the interface, we can ensure that book data in the program contains the essential information of title, author, and ISBN. If it doesn’t, the TypeScript compiler will throw an error:
const tale: Book = { title: 'A Tale of Two Cities', author: 'Charles Dickens', // Error: Property 'isbn' is missing in type '{ title: string; author: string; }' but required in type 'Book'. };
In our Book
interface example above, all the attributes are defined as required
. However, we can also define an interface to expect some optional attributes. This can be done by adding the ?
symbol in the attribute definition.
To demonstrate this, let’s add an optional Boolean attribute signedCopy
to our Book
interface:
interface Book { title: string; author: string; isbn: string; signedCopy?: boolean; }
If we’ve written TypeScript code before, we might be familiar with type aliases, another common way to name a type, and we might ask, “Why use interfaces over types or vice versa?”
The primary difference is that interfaces can be reopened for adding additional properties (via declaration merging) in different parts of the program, while type aliases cannot. In a later section, we’ll look at how to take advantage of declaration merging and some cases of when we might want to.
We can use the extend
keyword in TypeScript to inherit all the properties from an existing interface. Here’s an example. The Dog
and Fish
interfaces extend the Pet
interface, inheriting the name
and age
properties:
interface Pet { name: string; age: number; } interface Dog extends Pet { breed: string; } interface Fish extends Pet { finColor: string; } const betta: Fish = { name: 'Sophie', age: 2, finColor: 'black', };
This probably looks familiar to object-oriented programmers. However, interfaces offer a key feature not typically found in traditional object-oriented programming: multiple inheritance.
Multiple inheritance allows us to combine behaviors and properties of multiple interfaces into a single interface.
TypeScript supports multiple interface inheritance, enabling a new interface to inherit from multiple base interfaces. Here’s how it works:
interface Animal { eat(): void; } interface Bird { fly(): void; } interface eagle extends Animal, Bird { // Inherits both eat and fly methods }
In this example, the eagle
interface inherits properties and methods from both Bird
and Animal
interfaces. An object of type eagle
must include both fly
and eat
methods.
We can override properties and methods with new implementations when inheriting from an existing interface. This allows us to customize behavior while maintaining the original interface’s structure. Here’s an example:
interface Vehicle { startEngine(): void; } interface ElectricVehicle extends Vehicle { // Override startEngine method startEngine(): string; } const tesla: ElectricVehicle = { startEngine() { return 'Electric engine started'; }, };
In this case, the ElectricVehicle
interface overrides the startEngine
method to provide a different implementation.
As noted above, interfaces can be reopened to add new properties and expand the definition of the type. Here is a nonsensical example to illustrate this capability:
interface Stock { value: number; } interface Stock { tickerSymbol: string; }
Of course, it’s not likely that the same interface would be reopened nearby like this. It would be clearer to define it in a single statement:
interface Stock { value: number; tickerSymbol: string; }
So, when would we want to expand an interface in different parts of our program? Let’s look at a real-world use case.
Suppose we are writing a React application, and we need some pages that will allow the user to configure information such as their profile, notification preferences, and accessibility settings. For clarity and user experience, we’ve split these concerns into three separate pages with the source code in three files: Profile.tsx
, Notifications.tsx
, and Accessibility.tsx
.
From an application architecture perspective, it would be nice if all the user’s preferences were contained in a single object that adheres to an interface we’ll call Preferences
. This way, we can easily load and save the preferences object with our backend API with just one or two endpoints rather than several.
The next question is: “Where should the Preferences
interface be defined?” We could put the interface in its own file, preferences.ts
, and import
it into the three pages — or we could take advantage of declaration merging and have each page define only the properties of Preferences
that it cares about, like so:
// Profile.tsx interface Preferences { avatarUrl: string; username: string; } const Profile = (props) => { // ... UI for managing the user's profile ... } // Notifications.tsx interface Preferences { smsEnabled: boolean; emailEnabled: boolean; } const Notification = (props) => { // ... UI for managing the user's notification preferences ... } // Accessibility.tsx interface Preferences { highContrastMode: boolean; } const Accessibility = (props) => { // ... UI for managing the user's accessibility settings ... }
In the end, the Preferences
interface will resolve to contain all the properties as desired:
interface Preferences { avatarUrl: string; username: string; smsEnabled: boolean; emailEnabled: boolean; highContrastMode: boolean; }
The UI code is now co-located with only the properties of Preferences
it manages, making the program easier to understand and maintain. Nice!
Another way to expand interfaces in TypeScript is to inherit from an existing interface using the extend
keyword. As mentioned earlier, we can also extend a new interface from multiple interfaces.
Extending multiple interfaces refers to the concept of composition where the interface is designed to extend attributes it needs. It differs from the concept of inheritance in OOP where an object is a child of a given class and compulsorily extends the parent’s properties.
Let’s look at a use case for when we might want to do this.
Suppose we’re building an application that enables users to keep track of their to-do lists and daily schedules in one place. We’ll have some different UI components for tracking each of those tasks:
// todo-list.ts interface ToDoListItem { title: string; completedDate: Date | null; } interface ToDoList { todos: ToDoListItem[]; } // ... application code for managing to-do lists ... // calendar.ts interface CalendarEvent { title: string; start: Date; end: Date; } interface Calendar { events: CalendarEvent[]; } // ... application code for managing the calendar ...
Now that we’ve created the basic interfaces for keeping track of our two pieces of state, we would like a single interface that represents the state of the entire application. We can use the extends
keyword to create such an interface. We’ll also add a modified
field so that we know when our state was last updated:
interface AppState extends ToDoList, Calendar { modified: Date; }
Now we can use the AppState
interface to ensure that the application is properly handling the state:
function persist(state: AppState) { // ... save the state to a storage layer ... } persist({ todos: [ { title: 'Text Marcy', completedDate: new Date('2022-02-05') }, { title: 'Buy groceries', completedDate: null }, ], events: [ { title: 'Study', start: new Date('2022-02-11 08:00:00'), end: new Date('2022-02-11 10:00:00'), }, ], modified: new Date('2022-02-06'), });
While re-opening interfaces is not possible with type aliases, this approach of extending types is, but with some subtle differences in syntax. Here’s the equivalent example adapted to use type
instead of interface
:
type ToDoListItem = { title: string; completedDate: Date | null; } type ToDoList = { todos: ToDoListItem[]; } type CalendarEvent = { title: string; start: Date; end: Date; } type Calendar = { events: CalendarEvent[]; } type AppState = ToDoList & Calendar & { modified: Date; } function persist(state: AppState) { // ... save the state to a storage layer ... } persist({ todos: [ { title: 'Text Marcy', completedDate: new Date('2022-02-05') }, { title: 'Buy groceries', completedDate: null }, ], events: [ { title: 'Study', start: new Date('2022-02-11 08:00:00'), end: new Date('2022-02-11 10:00:00'), }, ], modified: new Date('2022-02-06'), });
Combining multiple type definitions is called intersection and is performed using the &
symbol. Learn more by reading up on powerful type options in TypeScript.
In TypeScript, generics enable us to create reusable code that works with multiple data types. Using interfaces with generics in TypeScript is a flexible approach to creating reusable and type-safe components and functions. It provides a way to define interfaces with various data types while maintaining type safety.
Generics are denoted by type parameters enclosed in angle brackets, such as <T>
. For example, we need a Repository
interface that can accept many different data types. We can define it with generics as shown below:
// Define a generic Repository interface interface Repository<T> { findById(id: string): T; save(item: T): void; }
The Repository<T>
interface includes two methods: findById
, which retrieves an entity by its ID, and save
, which saves an entity. The type parameter T
allows us to specify the entity type that the repository will work with.
We implement a BookRepository
class below using the Repository<T>
interface:
interface Book { title: string; ISBN: string; } // Create a BookRepository class that implements the Repository<Book> interface class BookRepository implements Repository<Book> { private books: Record<string, Book> = {}; findById(id: string): Book { return this.books[id]; } save(book: Book): void { this.books[book.ISBN] = book; } }
In the example, we implement the Repository<Book>
interface. Thus, the BookRepository
class has to conform to the contract defined in the interface. Any deviation from this contract would result in a compilation error, ensuring type safety. We can apply the same pattern to other entity types (e.g., Author, Publisher) by creating similar repository classes with different type parameters.
Generic constraints in TypeScript restrict the types that can be used with a generic parameter to ensure they have specific properties or characteristics.
In the above example, let’s assume we want to ensure that T
must have an id
property. Here’s how we can apply a generic constraint. We update the Repository
interface to include a generic constraint <T extends EntityWithId>
. This constraint ensures that T
must be a type that has an ID property:
interface EntityWithId { id: string; } // Define a generic Repository interface with a constraint interface Repository<T extends EntityWithId> { findById(id: string): T; save(item: T): void; } // The BookRepository remains unchanged.
The BookRepository
class still implements the Repository<Book>
interface, but now it enforces that only entities with an id
property can be used with this repository.
TypeScript compiler will throw an error at Repository<Book>
because the Book interface doesn’t contain an id
property:
To fix the issue, we need to add an id
property like below:
interface Book { id: string; title: string; ISBN: string; }
In summary, using generic constraints enhances type safety and prevents unintended usage.
Interfaces can be used to define a function or class’s expected properties — both required and optional. Let’s take a look at a few use cases.
We can use interfaces to define the shape of functions to ensure that they adhere to a specific structure when implemented. It is beneficial when we want to enforce consistency and type safety across the codebase.
Here is an example:
interface Book { title: string; isbn: string; } // Define a SearchBook interface for search functions interface SearchBook { (books: Book[], searchTerm: string): Book[]; } // Implement search functions that conform to the SearchBook interface const searchByTitle: SearchBook = (books, searchTerm) => { return books.filter((book) => book.title.toLowerCase().includes(searchTerm.toLowerCase()) ); }; const searchByISBN: SearchBook = (books, searchTerm) => { return books.filter((book) => book.isbn === searchTerm ); };
In this contrived example, we defined a SearchBook
interface, which specifies a function interface that takes an array of Book
objects and a searchTerm
(a string) as parameters and returns an array of Book
objects. Then, we implement two search functions, searchByTitle
and searchByISBN
, which conform to the SearchBook
interface.
Using the function interface, we ensure that all search functions have the same signature, making them interchangeable and providing a clear contract for how search functions should behave.
Passing an interface as a parameter in TypeScript allows us to define a contract that objects must adhere to when passed as arguments to functions or methods.
Below is an example of using an interface to specify the expected parameters and results of a function:
// function interface Person { firstName: string; lastName: string; age?: number; } interface Bio { fullName: string; yearOfBirth?: number; } function getBio(person: Person): Bio { let yearOfBirth: number | undefined; // is initially undefined if (person?.age) { const today = new Date(); yearOfBirth = today.getFullYear() - person.age; } return { fullName: `${person.firstName} ${person.lastName}`, yearOfBirth } } // TypeScript will throw an error like this // Argument of type '{ firstName: string; lastName: string; gender: string; }' is not // assignable to parameter of type 'Person'. // Object literal may only specify known properties, and 'gender' does not exist in type 'Person'.(2345) const result = getBio({firstName: 'John', lastName: 'Dugan', gender: 'Male'});
In our example above, we defined an interface Person
. This person
object has the required properties firstName
and lastName
along with an optional property age
, which is the expected function parameter. We also defined an interface Bio
with a required property fullName
and an optional property yearOfBirth
, which the function returns.
If an incorrect property is passed in the person
object, TypeScript will throw an error, ensuring strict type checking and adherence to the defined parameter interface.
We can define an interface for a class that specifies its expected properties and their shapes, then use the implements
keyword to apply this definition to the class:
interface Animal { name: string; canWalk(): boolean; } class Cat implements Animal { name: string; constructor() { this.name = 'Cat'; } canWalk() { return true; } } const cat = new Cat(); console.log(cat.canWalk());
In our example above, we have defined an interface Animal
that the class Cat
implements. This means Cat
must have all the required properties defined in Animal
.
When we discuss the pros and cons of interfaces, we are — to a large extent — discussing the pros and cons of TypeScript versus JavaScript. This is because typing and defining shapes via interfaces is a core feature of TypeScript.
Let’s start with the pros.
With TypeScript interfaces, we define what is expected and give our code consistency and dependability. By setting the type of variables, function parameters, or function results, we know what to expect and will get some compile-time errors alerting us that something is happening that is not allowed.
In the example below, our JavaScript function does not use a type to describe what is expected, which can lead to unexpected results:
function (a, b) => a + b // TypeScript function (a: number, b: number): number => a + b
A possible error in our JavaScript example is passing a string instead of a number. The outcome would be a string result (from concatenation) where a number was expected.
Compile-time errors in TypeScript occur at build time and, most importantly, before the function or code is run. This means errors are detected much sooner before they can cause any damage.
Finally, using interfaces to define code shapes makes managing the code base easier. By extension, this also has the potential to improve overall performance when working in a team.
Now, let’s explore some drawbacks of using interfaces in TypeScript.
It is easy to rely entirely on interfaces and types to prevent errors and have a false sense of security. While it helps to define the shapes of what is expected — and not expected — it isn’t infallible.
Additionally, implementing types and interfaces can easily and quickly complicate a code base. Just think about functions that take and handle different shapes or types of variables and the endless function overloading that can easily follow.
There are a few different ways to extend object-like types with interfaces in TypeScript, and sometimes, we may use type aliases.
In those cases, even the official docs say they are mostly interchangeable, at which point it’s down to style, preference, organization, habit, etc. But if we want to declare different properties on the same type in different parts of our program, use TypeScript interfaces.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
One Reply to "Extending object-like types with interfaces in TypeScript"
Option 0: composition over inheritance – frankly the best option of all. 🙂