Помимо базовых типов, TypeScript предоставляет разработчикам операторы для определения интерфейсов (interface) и псевдонимов для типов (type). В актуальных версиях TypeScript эти операторы во многих случаях взаимозаменяемы. Может сложиться впечатление, что кроме написания никаких различий между ними нет. На самом деле это не так. Разберёмся на примерах.

Псевдонимы типов

Оператор type позволяет определить новый псевдоним для существующего типа. Слово «псевдоним» используется неслучайно. Оператор фактически добавляет дополнительное имя для существующего типа. Новый тип данных при этом не создаётся. Взгляните на пример:

type OrderNumber = string;

Мы определили псевдоним типа OrderNumber. Фактически это дополнительное имя для типа string. При объявлении переменной типа OrderNumber, нам не придётся делать каких-то преобразований при попытке записать в неё строковое значение. Ведь OrderNumber — дополнительное имя для типа string:

const myOrderNumber: OrderNumber = '31337';

Запомните, оператор type не создаёт новый тип, а добавляет псевдоним. Мы специально делаем на этом акцент, так как в различной литературе и статьях при обсуждении оператора type используют словосочетание «новый тип». Это сделано для удобства повествования. Фактически новый тип не создаётся.

⭐ Узнайте больше о теории типов, научитесь на практике использовать аннотацию типов и обобщённое программирование на профессиональном курсе.

Взаимозаменяемость

Во многих ситуациях type и interface взаимозаменяемы. Неважно каким оператором воспользуетесь, больших отличий сразу не заметите. С одной стороны — это хорошо, с другой вводит путаницу. Рассмотрим на примере определение псевдонима типа для описания заказа в импровизированном интернет-магазине. С точки зрения кода, заказ удобно описать в виде объекта определённой формы. Воспользуемся псевдонимами типов:

type Order = {
  id: string;
  createdAt: Date;
  items: string[];
}

const myOrder: Order = {
  id: '31337',
  createdAt: new Date(),
  items: ['orange', 'banana'],
}

Сначала мы определяем псевдоним типа, а затем демонстрируем его применение.

Объект myOrder должен соответствовать структуре Order и если забыть определить какое-то свойство, компилятор сразу напомнит об этом.

Аналогичного результата можно добиться при помощи интерфейсов. Вместо оператора type воспользуемся interface:

interface Order {
  id: string;
  createdAt: Date;
  items: string[];
}

Пример, где определяется объект заказа типа Order остаётся без изменений. Получается, что одну и ту же задачу мы решили разными способами. Какому оператору отдавать предпочтение? В последних версиях TypeScript грань между операторами interface и type стала тонкой (пример выше хорошо иллюстрирует это), но разница всё же есть.

Слияние интерфейсов

Интерфейсы поддерживают декларативное слияние, а псевдонимы типов нет. Объявив два или более интерфейса с одинаковыми идентификаторами (именами), мы получим один общий интерфейс:

interface Order {
  id: string;
  createdAt: Date;
  items: string[];
}

interface Order {
  status: string;
}

interface Order {
  owner: string;
}

const myOrder: Order = {
  id: '31337',
  createdAt: new Date(),
  items: ['orange', 'banana'],
  status: 'created',
  owner: 'Michael Jackson',
}

Мы определили три интерфейса с одинаковым идентификатором (Order), а затем воспользовались им для определения нового заказа. Обратите внимание, это полностью валидный код. Никаких ошибок о дублировании идентификатора нет. TypeScript выполнил слияние и на выходе получился один интерфейс Order.

Слияние интерфейсов может показаться бессмысленной возможностью, но это не так. Лучше всего она себя проявляет при разработке библиотек. Разработчик получает возможность расширять интерфейс без внесения изменений в исходное определение. Клиенту всегда важен более гибкий интерфейс.

А что на счёт псевдонимов? Они так не умеют. При определении двух псевдонимов с одинаковыми идентификаторами (именами) возникнет ошибка: «Duplicate identifier Order».

Типы пересечения

При разработке на TypeScript часто возникает необходимость комбинировать различные типы. Один из вариантов комбинации — пересечение типов. Результатом пересечения становится тип с общими характеристиками типов, участвующих в пересечении.

Если сказать проще, при пересечении типов A и B, мы получим тип C. Ему соответствуют значения, которые одновременно принадлежат к типам A и B, то есть обладают обязательными характеристиками каждого типа.

В TypeScript эту задачу решает оператор амперсанд (&). Рассмотрим на примере.

type OrderIdentifier = {
  id: string;
}

type OrderStatus = {
  status: string;
}

type Order = OrderIdentifier & OrderStatus;

За счёт пересечения мы получаем новый тип Order. Он объединяет общие характеристики типов OrderIdentifier и OrderStatus. Получить новый тип пересечения возможно и на основании интерфейсов:

interface OrderIdentifier {
  id: string;
}

interface OrderStatus {
  status: string;
}

type Order = OrderIdentifier & OrderStatus;

Результат выполнения этого кода не отличается от предыдущего. Однако, при объединении интерфейсов мы получаем тип пересечения Order. Обратите внимание, мы получаем именно тип пересечения, а не интерфейс. Получить новый интерфейс пересечения не получится. Это ещё одно отличие между type и interface.

Типы объединения

Псевдонимы типов можно объединять. В результате объединения получается новый тип. Новый тип содержит всё что есть в типах, участвующих в объединении. Для объединения применяется оператор |. Рассмотрим на примере:

type Dog = {
  bark: () => void;
}

type Cat = {
  meow: () => void;
}

type Animal = Dog | Cat;

// Собака Гуффи
const goofy: Dog = {
  bark() {
    console.log('bark');
  }
}

// Кот Том
const tom: Cat = {
  meow() {
    console.log('meow');
  }
}


// Animal подойдёт для кошек
let animal: Animal = tom;

//  и для собак
animal = goofy;

Объединять можно не только псевдонимы, но и интерфейсы. Принцип тот же, что и при пересечении. Результатом станет новый псевдоним типа, объединяющий интерфейсы. Получить новый интерфейс в результате объединения не получится.

Интерфейсы и классы

Интерфейсы особенно удобны при использовании объектно-ориентированного подхода. Сначала проектируется интерфейс, а потом классы, которые его имплементируют. Для этого в TypeScript есть отдельная синтаксическая конструкция implements. Рассмотрим на примере:

interface Cat {
  meow: () => void;
}

class Tiger implements Cat {
  meow() {
    console.log('meow-meow-bark');
  }
}

Мы описали интерфейс Cat, а затем определили класс Tiger. Этот класс реализует интерфейс Cat. Обратите внимание на ключевое слово implements. Приведённый пример кода ещё одна демонстрация, что интерфейс можно заменить на псевдоним типа. Например, так:

type Cat = {
  meow: () => void;
}

Классы могут имплементировать псевдоним типа, поэтому пример с определением класса Tiger остаётся актуальным, а мы опять увидели взаимозаменяемость type и interface.

Пожалуй, можно переходить к следующему разделу, но у интерфейсов есть ещё один козырь в рукаве. Интерфейсы поддерживают наследование. Работает это точно так же, как и в классах. При наследовании интерфейсов применяется оператор extends:

interface Cat {
  meow: () => void;
}

interface FastCat extends Cat {
  run: () => void;
}

class Tom implements FastCat {
  meow() {
    console.log('meow');
  }

  run() {
    console.log('run');
  }
}

Интерфейс FastCat наследуется от интерфейса Cat. Таким образом, интерфейс FastCat включает всё, что есть в определении Cat. При имплементации интерфейса FastCat, класс должен реализовать оба метода meow и run.

Это ещё не всё. В качестве родителя для интерфейса может выступать класс. Новый интерфейс будет содержать поля и методы класса, а также то, что разработчик добавит в интерфейс. Наследуя интерфейс от класса, помните, что класс в этом случае не должен содержать приватных полей. Рассмотрим пример:

class Animal {
 walk() {
   console.log('walk');
 }

 run() {
   console.log('run');
 }
}

// Интерфейс включает методы
// класса Animal
interface Cat extends Animal {
  meow: () => void;
}

class HomeCat implements Cat {
  walk() {
    console.log('Cat can walk');
  }

  run() {
    console.log('Cat can run');
  }

  meow() {
    console.log('meow');
  }
}

Рассматривать пример следует с описания класса Animal. Обычный класс с двумя методами: walk и run. Затем мы определяем новый интерфейс Cat. Он наследуется от класса Animal, следовательно, новый интерфейс Cat включает контракт для методов walkrun (результат наследования) и meow.

Для проверки интерфейса определяем новый класс HomeCat. Он имплементирует интерфейс Cat, который в свою очередь наследуется от класса Animal. Это означает, что класс HomeCat должен имлементировать все три метода.

Псевдонимы типов не поддерживают наследования. Оно возможно только с интерфейсами. Это ещё одно различие между этими конструкциями.

Кортежи

TypeScript поддерживает кортежи. Кортеж — упорядоченный набор фиксированной длины. Кортежи похожи на массивы, даже синтаксис используется такой же. Но в отличии от последних, кортеж не может динамически расширяться, и типы значений, а также их количество известны заранее. В следующем примере объявляем новый кортеж Developer:

type Developer = [string, string, number];
const ivan: Developer = ['Ivan', 'Ivanov', 33];

Объявить новый кортеж с помощью interface нельзя. Это ещё одна ситуация, когда операторы не взаимозаменяемы. Стоит добавить, что внутри интерфейса определять кортежи можно:

interface Developer {
  name: string;
  top2skill: [string, string];
}

const ivan: Developer = {
  name: 'Ivan',
  top2skill: ['run', 'sing'],
}

Резюме

Во многих случаях операторы type и interface взаимозаменяемы. Мы убедились в этом на практике. Мы рассмотрели несколько ситуаций, когда поведение отличается и использование одного оператора вместо другого невозможно. А что делать с ситуациями, когда допустимы оба оператора?

Дать объективный ответ на этот вопрос сложно. Всё зависит от нескольких «но» и взглядов разработчика. Уместны оба варианта. Однако, мы рекомендуем по умолчанию применять type. Оператор interface актуален во время применения объектно-ориентированного подхода, а также при создании библиотек и готовых пакетов с компонентами.

В случае с ООП всё относительно понятно: интерфейсы часть этого мира. Фраза «реализовать интерфейс» разработчику привычней по сравнению с «реализовать тип». С этим трудно поспорить. К тому же вы помните про наследование интерфейсов. Эта возможность может оказаться полезной.

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