Меня зовут Дима. Я 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