Extending object-like types with interfaces in TypeScript

mangelosanto

Matt Angelosanto

Posted on March 10, 2022

Extending object-like types with interfaces in TypeScript

Written by Matthew Swensen✏️

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 dive into exploring interfaces’ characteristics and how we might better leverage them in our programs.

What are TypeScript interfaces?

First, a little background. Interfaces are a way for 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;
}


Enter fullscreen mode Exit fullscreen mode

With it, 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'.
};


Enter fullscreen mode Exit fullscreen mode

Interfaces vs. types

If you’ve written TypeScript code before, you might be familiar with type aliases, another common way to name a type, and you 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 your program, while type aliases cannot. Let’s look at how to take advantage of declaration merging, as well as some cases of when you might want to.

Expanding interfaces in TypeScript

Option 1: Declaration merging

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;
}


Enter fullscreen mode Exit fullscreen mode

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;
}


Enter fullscreen mode Exit fullscreen mode

So, when would you want to expand an interface in different parts of your program? Let’s look at a real-world use case.

Declaration merging to wrangle disparate user preferences

Suppose you are writing a React application, and you 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, you’ve split up these three concerns into three separate pages where the source code will be 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, you can easily load and save the preferences object with your backend API with just one or two endpoints rather than several.

The next question is: “Where should the Preferences interface be defined?” You could put the interface in its own file, preferences.ts, and import it into the three pages — or, you 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 ...
}


Enter fullscreen mode Exit fullscreen mode


// Notifications.tsx

interface Preferences {
  smsEnabled: boolean;
  emailEnabled: boolean;
}

const Notification = (props) => {
  // ... UI for managing the user's notification preferences ...
}


Enter fullscreen mode Exit fullscreen mode


// Accessibility.tsx

interface Preferences {
  highContrastMode: boolean;
}

const Accessibility = (props) => {
  // ... UI for managing the user's accessibility settings ...
}


Enter fullscreen mode Exit fullscreen mode

In the end, the Preferences interface will resolve to fully contain all the properties, as desired:



interface Preferences {
  avatarUrl: string;
  username: string;
  smsEnabled: boolean;
  emailEnabled: boolean;
  highContrastMode: boolean;
}


Enter fullscreen mode Exit fullscreen mode

The UI code is now co-located with only the properties of Preferences it manages, making the program easier to understand and maintain. Nice!

Option 2: Extending interfaces in TypeScript

Another way to expand interfaces in TypeScript is to mix one or more of them into a new interface.



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',
};


Enter fullscreen mode Exit fullscreen mode

This probably looks familiar to object-oriented programmers. However, interfaces offer a key feature that is 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. Let’s look at a use case for when you might want to do this.

Extending interfaces to form a type-safe global state store

Suppose that you’re building an application that enables users to keep track of their to-do lists and their daily schedules in one place. You’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 ...


Enter fullscreen mode Exit fullscreen mode


// calendar.ts

interface CalendarEvent {
  title: string;
  start: Date;
  end: Date;
}

interface Calendar {
  events: CalendarEvent[];
}

// ... application code for managing the calendar ...


Enter fullscreen mode Exit fullscreen mode

Now that you’ve created the basic interfaces for keeping track of your two pieces of state, you 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;
}


Enter fullscreen mode Exit fullscreen mode

Now you 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'),
});

Enter fullscreen mode Exit fullscreen mode




Extending types

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'),
});

Enter fullscreen mode Exit fullscreen mode




Conclusion

There are a few different ways to extend object-like types with interfaces in TypeScript, and, sometimes, you 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 you’d like to declare different properties on the same type in different parts of your program, use TypeScript interfaces.


Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

Write More Readable Code with TypeScript 4.4

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on March 10, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related