Типы или интерфейсы в TypeScript: что и когда использовать?

в 21:45, , рубрики: interfaces, types, TypeScript

Меня зовут Дима. Я Frontend разработчик в компании fuse8. Работая с TypeScript, рано или поздно сталкиваешься с вопросом: что выбрать — типы или интерфейсы? В нашей команде мы активно используем TypeScript, уделяя особое внимание типам. В статье я хотел бы поделиться особенностями работы с типами и интерфейсами, которые могут быть полезны в вашей практике.

Основные отличия типов и интерфейсов

Типы используются для задания именованных типов данных, включая примитивы, объекты, функции и массивы. Они позволяют объединять или пересекать типы и поддерживают использование ключевых слов typeof, keyof при присвоении.

Интерфейсы служат для описания структуры объектов. Интерфейсы поддерживают декларативное объединение и могут быть расширены другими интерфейсами или классами.

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

Для примитивов и кортежей используйте типы

Создать строковый, числовой или другой примитивный тип с помощью интерфейса просто не получится.

Пример с примитивами:

type UserId = string;
type ColumnHeight = number;
type isActive = boolean;

В интерфейсах примитивные типы можно использовать в описании свойств объектов:

interface User {
  id: string;
  age: number;
  isActive: boolean;
}

Пример с кортежем:

type Coordinates = [number, number];

Добиться похожего поведения можно и с помощью интерфейса, но так не рекомендуется делать:

 interface Coordinates {
     0: number;
     1: number;
     length: 2; // фиксированная длина
 }

Интерфейсы с одинаковыми именами объединяются

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

Рассмотрим пример:

interface User {
  id: number;
}

interface User {
  name: string;
}

const user: User = {
  id: 100,
  name: 'John Doe'
};

В этом примере два интерфейса User сливаются в один, который содержит оба свойства: id и name. Это позволяет гибко добавлять новые поля к уже существующим структурам, не трогая оригинальный код. Если бы вы пытались сделать то же самое с типами, TypeScript выдал бы ошибку — названия типов должны быть уникальными, даже если типы находилсь находились бы в разных файлах.

Объединение происходит не на уровне одного файла, а на уровне всего проекта. Поэтому важно помнить, особенно, если проект большой, что есть возможность случайно расширить уже существующий интерфейс. Также это правило работает для предустановленных интерфейсов, например, если нужно затипизировать комментарий с помощью интерфейса, выбрав название Comment, то мы расширим интерфейс Comment, который находится в lib.dom.d.ts.

Для большего погружения можно ознакомиться с документацией по объединению интерфейсов.

Типы можно пересекать и объединять, интерфейсы – наследовать

Пересечение типов осуществляется с помощью оператора &:

type User = { id: string; };
type Article = { title: string; };

type UserArticle = User & Article;

Здесь UserArticle объединяет свойства как пользователя, так и статьи.

Похожего поведения в интерфейсах можно добиться с помощью ключевого слова extends:

interface User {
 id: string;
}

interface Article {
 title: string;
}

interface UserArticle extends User, Article {}

Но это не одно и тоже, extends используется только для интерфейсов и подразумевает наследование, тогда как пересечение типов с помощью & может использоваться как для интерфейсов, так и для любых других типов.

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

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

Другое отличие заключается в том, что если оба типа являются объектами, и в этих объектах содержатся поля с одинаковыми названиями, но разными типами, то extends выдаст ошибку, а при использовании& ошибки не будет. Рассмотрим пример:

type User = {
 id: string;
}

type Article = {
 id: number;
}

type UserArticle = User & Article;

В UserArticle ошибки нет, но id имеет тип never, так как id не может быть одновременно и строкой и числом. А при использовании extends получаем ошибку:

Типы также поддерживают объединение с помощью оператора |. Это удобно, когда тип может быть один из нескольких вариантов:

type User = {
 id: string;
}

interface Article {
 title: string;
}

type ProductId = string;

type Payload = User | Article | ProductId;

Лаконичность типов при использовании Utility Types

Типы имеют более более лаконичный синтаксис при использовании Utility Types, чем интерфейсы. Например, для создания типа с необязательными полями можно воспользоваться утилитой Partial.

Вот как это выглядит для типов:

type User = {
  id: string;
}

type UserPartial = Partial<User>;

Теперь давайте посмотрим, как это будет выглядеть с интерфейсом:

interface User {
  id: string;
}

interface UserPartial extends Partial<User> {}

В случае с интерфейсом нам приходится добавлять дополнительные конструкции extends и пустые фигурные скобки {}, что делает код менее читабельным. Это не критично, но может добавлять лишний «шум», особенно если часто используются такие утилиты как Partial, Pick, Omit и другие.

Свойства интерфейсов сохраняют источник

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

Пример:

interface User {
  id: string;
}

interface Article {
  name: string;
}

interface UserArticle extends User, Article {};

const userArticle: UserArticle = {
  id: 'test',
  name: 'test'
};

Если вы посмотрите на объект userArticle, поле id будет связано с User.id: string, а name — с Article.name: string. Это может помочь лучше понять, откуда взято конкретное свойство при сложных наследованиях.

Теперь давайте перепишем тот же пример на типах:

type User = {
  id: string;
}

type Article = {
  name: string;
}

type UserArticle = User & Article;

const userArticle: UserArticle = {
  id: 'test',
  name: 'test'
};

В случае с типами при отладке оба поля id и name будут просто строками (string), и информация о том, откуда они взяты, будет потеряна.

Когда использовать типы, а когда интерфейсы?

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

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

Полезные ссылки:

Автор: berdnikovdim

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js