Comparing Types and interfaces in TypeScript

Comparing Types and interfaces in TypeScript

Sunny Sun Lv4
  • The post was updated on 29-07-2024

TypeScript offers two primary mechanisms for defining custom types: interfaces and type aliases. While both serve a similar purpose of imposing structure on data, they have distinct characteristics and use cases. There are cases where one has a clear advantage over the other, but in many cases they are interchangeable.

This article delves into the nuances of interfaces and types, providing guidance on when to use each approach to optimize your TypeScript codebase.

Let’s start with the basics of types and interfaces.

Types vs type aliases

type is a keyword in TypeScript that we can use to define the shape of data. The basic types in TypeScript include:

  • String
  • Boolean
  • Number
  • Array
  • Tuple
  • Enum
  • Advanced types

Each of these comes with its unique features and purposes, allowing developers to choose the appropriate one for their particular use case.

Type aliases in TypeScript mean “a name for any type.” They provide a way of creating new names for existing types. Type aliases don’t define new types; instead, they simply provide an alternative name for an existing type.

Type aliases can be created using the type keyword, and can refer to any valid TypeScript type, including primitive types.

1
2
3
4
5
6
type MyNumber = number;
type User = {
id: number;
name: string;
email: string;
}

In the above example, we create two type aliases: MyNumber and User. We can use MyNumber as shorthand for a number type, and use User type aliases to represent the type definition of a user.

When we say “types versus interfaces,” what we are actually referring to is “type aliases versus interfaces”. For example, you can create the following aliases:

1
2
type ErrorCode = string | number;
type Answer = string | number;

The two type aliases above represent alternative names for the same union type: string | number. While the underlying type is the same, the different names express different intents, which makes the code more readable.

Interfaces overview

In TypeScript, an interface is a way to define the shape or structure of an object. It specifies the properties and methods that an object can have, along with their types. Interfaces are used to ensure that objects adhere to a specific contract, making TypeScript code more predictable and type-safe. Below is an example:

1
2
3
4
interface Client { 
name: string;
address: string;
}

We can express the same Client contract definition using type annotations:

1
2
3
4
type Client = {
name: string;
address: string;
};

Key Features of Interfaces

  • Property and method Definitions: Interfaces define the names and types of properties or methods that an object should have.

  • Optional Properties: Interfaces can specify optional properties using a question mark (?). This indicates that the property is not required on objects that implement the interface.

  • Read-Only Properties: You can mark properties as read-only using the readonly keyword which means the property can be assigned a value only once.

  • Extensibility: Interfaces can extend other interfaces, allowing for the combination of multiple interfaces into a single one.

Comparing types and interfaces

For the above case, you can use either type or interface. But there are some scenarios in which using type instead of interface makes a difference.

Declaration merging

Declaration merging is a feature that is exclusive to interfaces. With declaration merging, we can define an interface multiple times and the TypeScript compiler will automatically merge these definitions into a single interface definition.

In the following example, the two Client interface definitions are merged into one by the TypeScript compiler, and we have two properties when using the Client interface:

1
2
3
4
5
6
7
8
9
10
11
12
interface Client { 
name: string;
}

interface Client {
age: number;
}

const harry: Client = {
name: 'Harry',
age: 41
}

Type aliases can’t be merged in the same way. If you try to define the Client type more than once, as in the above example, an error will be thrown:

alt_text

When used in the right places, declaration merging can be very useful. One common use case for declaration merging is to extend a third-party library’s type definition in order to fit the needs of a particular project.

If you find yourself needing to merge declarations, interfaces are the way to go.

Extends vs. intersection

An interface can extend one or multiple interfaces. By using the extends keyword, a new interface can inherit all the properties and methods of an existing interface, while also adding new properties.

For example, we can create a VIPClient interface by extending the Client interface:

1
2
3
interface VIPClient extends Client {
benefits: string[]
}

To achieve a similar result for types, we need to use an intersection operator:

1
type VIPClient = Client & {benefits: string[]}; // Client is a type

You can also extend an interface from a type alias with statically known members:

1
2
3
4
5
6
7
type Client = {
name: string;
};

interface VIPClient extends Client {
benefits: string[]
}

The exception is union types. Union types allow us to describe values that can be one of several types and create unions of various primitive types, literal types, or complex types.

There is no equivalent to a union type in an interface. If you try to extend an interface from a union type, you’ll receive the following error:

1
2
3
4
5
type Jobs = 'salary worker' | 'retired';

interface MoreJobs extends Jobs {
description: string;
}

alt_text

This error occurs because the union type is not statically known. The interface definition needs to be statically known at compile time.

Type aliases can extend interfaces using the intersection, as below:

1
2
3
4
interface Client {
name: string;
}
Type VIPClient = Client & { benefits: string[]};

In a nutshell, both interfaces and type aliases can be extended. An interface can extend a statically known type alias, while a type alias can extend an interface using an intersection operator.

Handling conflicts when extending

Another difference between types and interfaces is how conflicts are handled when you try to extend from one with the same property name.

When extending interfaces, the same property key isn’t allowed, as in the example below:

1
2
3
4
5
6
7
interface Person {
getPermission: () => string;
}

interface Staff extends Person {
getPermission: () => string[];
}

An error is thrown because a conflict is detected.

alt_text

Type aliases handle conflicts differently. In the case of a type alias extending another type with the same property key, it will automatically merge all properties instead of throwing errors.

In the following example, the intersection operator merges the method signature of the two getPermission declarations, and a typeof operator is used to narrow down the union type parameter, so we can get the return value in a type-safe way:

1
2
3
4
5
6
7
8
9
10
11
12
13
type Person = {
getPermission: (id: string) => string;
};

type Staff = Person & {
getPermission: (id: string[]) => string[];
};

const AdminStaff: Staff = {
getPermission: (id: string | string[]) =>{
return (typeof id === 'string'? 'admin' : ['admin']) as string[] & string;
}
}

It is important to note that the type intersection of two properties may produce unexpected results. In the example below, the name property for the extended type Staff becomes never, since it can’t be both string and number at the same time:

1
2
3
4
5
6
7
8
9
type Person = {
name: string
};

type Staff = person & {
name: number
};
// error: Type 'string' is not assignable to type 'never'.(2322)
const Harry: Staff = { name: 'Harry' };

In summary, interfaces will detect property or method name conflicts at compile time and generate an error, whereas type intersections will merge the properties or methods without throwing errors. Therefore, if we need to overload functions, type aliases should be used.

Creating classes using interfaces or type aliases

In TypeScript, we can implement a class using either an interface or a type alias:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface Person {
name: string;
greet(): void;
}

class Student implements Person {
name: string;
greet() {
console.log('hello');
}
}

type Pet = {
name: string;
run(): void;
};

class Cat implements Pet {
name: string;
run() {
console.log('run');
}
}

As shown above, both interfaces and type aliases can be used to implement a class similarly; the only difference is that we can’t implement a union type.

1
2
3
4
5
6
type primaryKey = { key: number; } | { key: string; };

// can not implement a union type
class RealKey implements primaryKey {
key = 1
}

alt_text

In the above example, the TypeScript compiler throws an error because a class represents a specific data shape, but a union type can be one of several data types.

Working with Tuple types

In TypeScript, Tuple type allows us to express an array with a fixed number of elements, where each element has its own data type. It can be useful when you need to work with arrays of data with a fixed structure:

1
type TeamMember = [name: string, role: string, age: number];

Interfaces don’t have direct support for tuple types. Although we can create some workarounds like in the example below, it is not as concise or readable as using the tuple type:

1
2
3
4
5
6
7
interface ITeamMember extends Array<string | number> 
{
0: string; 1: string; 2: number
}

const peter: ITeamMember = ['Harry', 'Dev', 24];
const Tom: ITeamMember = ['Tom', 30, 'Manager']; //Error: Type 'number' is not assignable to type 'string'.

Advanced type features

TypeScript provides a wide range of advanced type features that can’t be found in interfaces. Some of the unique features in TypeScript include:

  • Type inferences: Can infer the type of variables and functions based on their usage. This reduces the amount of code and improves readability
  • Conditional types: Allow us to create complex type expressions with conditional behaviors that depend on other types
  • Type guards : Used to write sophisticated control flow based on the type of a variable
  • Mapped types: Transforms an existing object type into a new type
  • Utility types: A set of out-of-the-box utilities that help to manipulate types

TypeScript’s typing system is constantly evolving with every new release, making it a complex and powerful toolbox. The impressive typing system is one of the main reasons why many developers prefer to use TypeScript.

Choose between types and interfaces

Type aliases and interfaces are very similar, but have some subtle differences, as shown in the previous section.

When should we use interfaces

While almost all interface features are available in types or have equivalents, there is one exception: declaration merging. In general, interfaces should be used in scenarios where declaration merging is necessary, such as extending an existing library or authoring a new library. Additionally, if you prefer the object-oriented inheritance style, using the extends keyword with an interface is often more readable than using the intersection with type aliases.

However, many of the features in types are difficult or impossible to achieve with interfaces. For example, TypeScript provides a rich set of features like conditional types, generic types, type guards, advanced types, and more. You can use them to build a well-constrained type system to make your app strongly typed. This can’t be achieved by the interface.

When types are better

In many cases, they can be used interchangeably depending on personal preference. But, we should use type aliases in the following use cases:

  • To create a new name for a primitive type
  • To define a union type, tuple type, function type, or another more complex type
  • To overload functions
  • To use mapped types, conditional types, type guard, or other advanced type features

Compared with interfaces, types are more expressive. There are many advanced type features that are not available in interfaces, and those features continue to grow as TypeScript evolves.

In addition, many developers prefer to use types because they are a good match with the functional programming paradigm . The rich type expression makes it easier to achieve functional composition, immutability, and other functional programming capabilities in a type-safe manner.

Conclusion

TypeScript offers both interfaces and type aliases for defining custom types. While they share similarities, understanding their nuances is crucial for making informed decisions in your codebase.

While both mechanisms serve their purpose effectively, personal preference often plays a role in the final choice. I lean towards using types, simply because of the amazing type system. What are your preferences? You are welcome to share your opinions in the comments section below.

  • Title: Comparing Types and interfaces in TypeScript
  • Author: Sunny Sun
  • Created at : 2023-04-06 00:00:00
  • Updated at : 2024-07-28 14:33:11
  • Link: http://coffeethinkcode.com/2023/04/06/type-interface-ts/
  • License: This work is licensed under CC BY-NC-SA 4.0.