Разница между типом и интерфейсом в TypeScript

Тип и интерфейс в TypeScript часто вызывают путаницу по причине поверхностной схожести. Ситуацию усугубляют устаревшие статьи, необъективные сравнения и style-guid'ы некоторых фреймворков. Например, в Angular по умолчанию включено tslint правило interface-over-type-literal, которое требует использовать интерфейсы вместо типов везде, где возможно. В этой статье разберём разницу между типом и интерфейсом в TypeScript и определимся что же использовать.

Сходства

Интерфейсы и типы могут использоваться для описания структур данных:

type Employee = {
  salary: number;
}

// Или

interface Employee {
  salary: number;
}

Могут использоваться для типизации функции:

interface CalculateSalary {
  (employee: Employee): number; 
}

// Или

type CalculateSalary = (employee: Employee) => number;

Интерфейсы и типы могут быть имплементированы классами:

type Employee = {
  giveEstimate(task: Task): number;
}

// Или

interface Employee {
  giveEstimate(task: Task): number;
}

class YoungDeveloper implements Employee {
  giveEstimate(task: Task): number {
    return task.complexity * 0.01;
  }
}

class MatureDeveloper implements Employee {
  giveEstimate(task: Task): number {
    return task.complexity * random(10, 1000) * Math.PI;
  }
}

Интерфейсы и типы позволяют выразить пересечение типов:

type TwitterProfile = Photographer & Musician & Entrepreneur & CoffeeDrinker;

// Или

interface TwitterProfile extends Photographer, Musician, Entrepreneur, CoffeeDrinker {};

Отличие 1 - Mapped типы

Интерфейсы нельзя комбинировать с mapped типами (Required, Pick, Readonly, Partial и прочими):

// С типом работает
type RealProfile = Pick<TwitterProfile, 'drinkCoffee'>;

// С интерфейсом не работает
interface RealProfile extends Pick<TwitterProfile, 'drinkCoffee'> {};

Интерфейс может расширять только интерфейсы, классы или другие типы, поэтому код нужно переписать так:

type OnlyDrinksCoffee = Pick<TwitterProfile, 'drinkCoffee'>;

interface RealProfile extends OnlyDrinksCoffee {}

По этой же причине только с помощью типа можно требовать, чтобы все свойства были обязательны или наоборот опциональны:

type TraineeDeveloper = Partial<{
  salary: number;
  sleep: boolean;
  eat: boolean;
}>

const trainee: TraineeDeveloper = {} // Может существовать без ЗП, еды и сна

Отличие 2 - Union

Интерфейсы позволяют выразить пересечение типов, однако не позволяют выразить объединение. Пример ограничения на типах, которое нереализуемо на интерфейсах:

type Wish = 
  | { fast: true, quality: true, cheap: false } // Дорого
  | { fast: true, quality: false, cheap: true } // Некачественно
  | { fast: false, quality: true, cheap: true } // Медленно

const wish: Wish = { fast: true, quality: true, cheap: true } // Не компилируется

Ещё интерфейсы не могут расширять union типы.

Отличие 3 - Declaration merging

Интерфейсы поддерживают declaration merging - слияние интерфейсов с одинаковыми именами:

interface Employee {
  salary: number;
}

interface Employee {
  age: number;
}

const employee: Employee = { age: 23 }; // Ошибка компиляции, так как не выдали salary

Эту особенность можно использовать если тайпинги сторонней библиотеки устарели, а вам нужно расширить интерфейс недостающими свойствами и методами. Если вы делаете свою библиотеку, то тут вам решать, стоит ли давать такие точки расширения функциональности. Если библиотека на TypeScript - то не стоит, так как декларации типов на выходе всегда будут актуальными и пользователю не понадобится исправлять расхождения между типами и рантаймом. Сейчас всё больше библиотек пишутся сразу на TypeScript или поставляются с тайпингами, поэтому необходимость использовать declaration merging возникает всё реже. Вдобавок исправить в устаревшем интерфейсе можно далеко не всё - нельзя удалить свойство или поменять тип существующего:

interface Employee {
  salary: number;
}

interface Employee {
  salary?: number;
}

const employee: Employee = {}; // Ошибка, тип Employee всё ещё требует salary

Отличие 4 - Рекурсивные типы

До TypeScript 3.7 были отличия в том, как работают рекурсивные типы и интерфейсы. С помощью типов нельзя было типизировать рекурсивные структуры, однако в новых версиях языка проблемы нет. Подробности в release notes языка.

Отличие 5 - Совместимость с типом Record

Из-за того, что interface поддерживает declaration merging (может быть расширен в любом месте) его нельзя использовать там, где ожидается Record<string, any>. Это может быть проблемой в ситуациях, где нужно использовать URLSearchParams или другое браузерное API, ожидающее Record<string, any>

interface Employee {
  name: string;
}

const employee: Employee = { name: '' }

new URLSearchParams(employee);

Этот код не скомпилируется с интерфейсом, зато будет работать если интерфейс поменять на тип.

Итог

Типы являются более предпочтительным вариантом, так как вы можете заменить интерфейсы типами, но не наоборот. Для использования продвинутой функциональности TypeScript - mapped типов, объединения типов и условных типов интерфейсы не подойдут.