Типы или интерфейсы в 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), и информация о том, откуда они взяты, будет потеряна.

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/844990/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *