Mastering TypeScript: Types vs. Interfaces

TypeScript enhances JavaScript by introducing optional static typing, resulting in more reliable and efficient code. While TypeScript offers a rich feature set, understanding the subtle differences between interfaces and types can be challenging. These concepts have grown increasingly similar, often making their use interchangeable. To master TypeScript, it's essential to grasp the distinct characteristics of interfaces and types, their appropriate applications, and how to achieve the same outcomes using either approach.

NOTES: For clarity in the examples, I'll use I to denote interfaces and T for types. However, it's important to note that prefixes are generally discouraged. They offer little benefit and doesn't align with common coding standards.

Let's start by examining the common ground between type aliases and interfaces. Both serve as blueprints for defining variable types in TypeScript.

type TDog = {
  name: string;
  size: string;
};

interface IDog {
  name: string;
  size: string;
}

Both TDog and IDog will produce the same error if you attempt to assign an additional property:

const lain: TDog = {
  name: 'Pedro Lain',
  size: 'large',
  breed: 'Briard', // Error: Object literal may only specify known properties, and 'breed' does not exist in type 'TDog'.
};

const dante: IDog = {
  name: 'Dante',
  size: 'large',
  breed: 'Peruvian Inca Orchid', // Error: Object literal may only specify known properties, and 'breed' does not exist in type 'IDog'.
};

Both interfaces and types can be used to define index signatures:

type TDict = {
  [key: string]: string;
};
const phoneBook: TDict = {
  john: '+1 (105) 342-7029',
  jane: '+1 (623) 085-1841',
};

interface IDict {
  [key: string]: number;
}
const leaderBoard: IDict = {
  john: 377,
  jane: 679,
};

Function types can also be defined using either type aliases or interfaces:

type TGreet = (name: string) => string;
const formalGreeter: TGreet = (name) => `It is nice to meet you ${name}.`;

interface IGreet {
  (name: string): string;
}
const informalGreeter: IGreet = (name) => `How's it going ${name}?`;

Generic versions of type aliases and interfaces are supported as well:

type TPerson<T> = { traits: T };
const joeBloggs: TPerson<string[]> = {
  traits: ['honesty', 'patience'],
};

interface IPerson<T> {
  traits: T;
}
const joeSchmo: IPerson<string[]> = {
  traits: ['kindness', 'intelligence'],
};

New types can be derived from existing ones:

type THuman = { name: string; age: number };
type TEngineer = { type: string } & THuman;
const johnDoe: TEngineer = { name: 'John Doe', age: 27, type: 'software' };

Similarly, interfaces can be built upon existing interfaces:

interface IHuman {
  name: string;
  age: number;
}
interface IEngineer extends IHuman {
  type: string;
}
const johnSmith: IEngineer = {
  name: 'John Smith',
  age: 23,
  type: 'Electrical',
};

Types aliases can extend interfaces:

type TCloudEngineer = { skills: string[] } & IEngineer;
const janeDoe: TCloudEngineer = {
  name: 'Jane Doe',
  age: 19,
  type: 'Cloud',
  skills: ['AWS', 'K8s'],
};

Interfaces can also be derived from type aliases, however, there are some caveats to be aware of, which we will cover shortly:

interface ICloudEngineer extends TEngineer {
  skills: string[];
}
const joeBlow: ICloudEngineer = {
  name: 'Joe Blow',
  age: 21,
  type: 'Cloud',
  skills: ['Google Cloud'],
};

Classes can inherit from either type aliases or interfaces:

class SecurityEngineer implements TEngineer {
  name: string;
  age: number;
  type: string;
}

class DevOpsEngineer implements IEngineer {
  name: string;
  age: number;
  type: string;
}

Now let's discuss the differences between type aliases and interfaces. While interfaces can extend other types, there are restrictions. One such limitation is the inability to extend union types:

type TSeason = 'summer' | 'winter' | 'autumn' | 'spring';
interface IWeather extends TSeason {} // Error: An interface can only extend an object type or intersection of object types with statically known members.

Type aliases provide a way to define custom names for built-in data types:

type TStringOrNumber = string | number;
const magicNumber: TStringOrNumber = 1;

In contrast, interfaces are designed for structural typing, outlining the blueprint of objects. Unlike objects, primitive data types are indivisible values, making them incompatible with interface definitions.

Another common use case is working with tuples. A type alias can represent a tuple like this:

type TTuple = [string, string, string];
const johnFavourites: TTuple = ['John', 'likes', 'oranges'];

Whereas an interface would represent the same thing as this:

interface ITuple {
  0: string;
  1: string;
  2: string;
  length: 3;
}
const janeFavourites: ITuple = ['Jane', 'loves', 'strawberries'];

Types offer greater expressiveness, with type aliases serving as a particularly intuitive and practical choice for defining tuple structures.

We've discussed the benefits of type aliases, but what are the advantages of using interfaces? Let’s begin with the following example:

interface Request {
  method: string;
}
interface Request {
  ip: string;
}
const req: Request = {
  method: 'GET',
  ip: '127.0.0.1',
};

This is known as declaration merging. Declaration merging in TypeScript allows multiple declarations with the same name to be combined into a single definition. This means you can extend or augment existing interfaces, namespaces, or classes across different parts of your code. For example, declaring the same interface multiple times will merge their properties and methods, making it easier to extend or add new features without redefining the entire structure.

Feel free to play around with the code snippets. You can find the interactive environment here.

Things to Remember

  • Grasp the distinctions and similarities between working with types and interfaces.
  • For complex type structures, type aliases are the preferred tool. They support advanced features like template literals and conditional logic for crafting sophisticated types.
  • Adhere to your project's coding conventions for consistency. If interfaces are the chosen approach, use them exclusively. Similarly, if type aliases are preferred, stick to using type aliases.
  • If there is no established coding style, interface augmentation can be a useful approach. For libraries intended for user customization, interfaces are recommended. For libraries that should remain unaltered, type aliases are preferable to avoid unintended modifications.