Типы и интерфейсы
Я работаю руководителем направления клиентской разработки и временами нанимаю фронтендеров. Последний найм-марафон продлился чуть более месяца, и я поговорил примерно с десятком кандидатов.
Во время этих всех собеседований я спрашивал у кандидатов в том числе и такой вопрос: — Когда ты используешь типы, а когда интерфейсы?
Вроде бы простой выбор, с которым каждый день десятки раз сталкивается разработчик. Но ответы заставили меня задуматься и пересмотреть свое отношение к этому вопросу.
Я обнаружил, что по ответу на этот вопрос можно с высокой вероятностью понять опыт кандидата. Если кандидат говорит, что для типизации пропсов в реактике он использует интерфейсы, то в большинстве случаев он начал использовать тайпскрипт ещё, когда реакт был на классах. Фронтендеры же новой школы поголовно всё типизируют типами. И с этим, в принципе, нет никаких проблем, кроме одной.
Ни те, ни другие не могут однозначно сказать на основе чего делают выбор. Поголовно это чисто интуитивное решение: из серии «прокатит — оставляем, не прокатило — переделываем». И их можно понять, эта схема работает довольно хорошо, зачем что-то менять. Но что я хочу услышать от кандидата?
Я не требую изложить мне всю теорию. Мне достаточно услышать хотя бы базовые три пункта: — во многих случаях они взаимозаменяемы — в библиотеках лучше экспортировать интерфейсы, так как их можно расширить — в тип можно записать любой существующий тип и не только. Этого более чем достаточно, чтобы ответ меня устроил. Но я задумался, а знаю ли я сам ответ на этот вопрос на 100%?
Я полез в документацию и пропал. Мне кажется я раскопал столько всего странного и необычного, с чем не сталкивается типичный разработчик. И всё это только вокруг вопроса про базовые понятия типов и интерфейсов. Я сел записывать видео, начинал несколько раз, но каждый раз я понимал, что видео будет не полным, потому что вот есть еще такое и такое. А еще вот тут отличается. А еще и такую штуку можно делать. Офигеть!
Содержание
- Начинаем погружение
- Немного терминологии
- Расширение
- Классы
- Дженерики
- Расширение интерфейсов — Declaration Merging
- Возможности псевдонимов
- Итоги
Начинаем погружение!
Путь предстоит долгий, поэтому нужно хорошенько подготовиться и вспомнить основы. Давайте на берегу вспомним как определяются интерфейсы и типы, в чем отличие и как все это дело связано.
Интерфейсы описывают объекты. Выглядит это следующим образом:
interface AInt {
first: boolean;
second: number;
}
Здесь мы определили интерфейс с именем AInt и двумя полями: first и second. Надеюсь на этом этапе все понятно.
Такую же структуру объекта мы можем описать и с помощью типа:
type AType = {
first: boolean;
second: number;
}
Обратите внимание, здесь мы использовали ключевое слово type, и после названия добавили знак =. Это первое отличие в синтаксисе. Но в остальном отличий нет.
Теперь мы можем создать объекты, которые будут соответствовать нашим AInt и AType:
const aInt: AInt = {
first: true,
second: 10,
}
const aType: AType = {
first: false,
second: 42,
}
Как видите, разницы никакой. Более того, мы можем сделать функцию, которая принимает аргументы с типом AInt и подсунуть туда aType, код отработает без ошибок:
function useInterface(param: AInt): void {
console.log(param);
}
useInterface(aInt); // ошибки нет
useInterface(aType); // и здесь ошибки нет тоже
TypeScript использует механизм структурной типизации — совместимость типов определяется на основе их структуры. Проще говоря: «выглядит как утка, крякает как утка, значит это утка», прямо как в JS. А вот в языках типа C# или Java такое не прокатит, потому что в них используется более строгая типизация — номинативная.
Немного терминологии
Прежде чем углубляться дальше, синканемся по терминологии, чтобы дальнейший разговор был на одном языке. В русскоязычном интернете особо не встречал, а вот в англоязычном наткнулся на следующее разделение: псевдонимы, интерфейсы и классы. Это очень помогает, потому что раньше у меня мозг взрывался когда хотел написать «тип задаем с помощью типа, а тут тип задаем с помощью интерфейса» — получалась какая-то тавтология.
Теперь всё встало на свои места, и можно сделать базовые выводы:
- Классы, интерфейсы и псевдонимы (они же алиасы) — это всё типы.
- Тип можно описать с помощью класса. В этом случае получим все ништяки интерфейса и JavaScript-класса.
- Можно это сделать с помощью интерфейса. Тогда у типа будет своё имя и появится возможность расширяться.
- А можно создать анонимный тип и сохранить его в переменную псевдонима. Исторически в тултипах IDE типы, созданные через
type, отображались как развёрнутая структура, аinterface— по имени. Начиная с TypeScript ~4.2 алиасы стали лучше сохранять свои имена, но различия в отображении всё ещё встречаются. В рантайме же (дебаггер браузера) разницы нет — все типы стираются при компиляции.
Запомнили? Отлично, идём дальше.
Расширение
Цель этого блока — получить в результате описание объекта с тремя полями: first, second и third.
Давайте возьмем уже знакомые нам тип AType и интерфейс AInt и на их основе создадим новые.
Интерфейсы расширяются с помощью ключевого слова extends:
interface BInt extends AInt {
third: string;
}
С типами такую запись мы использовать не можем. Поэтому нам на помощь приходит инструмент «пересечение», который обозначается символом &. В результате мы получаем запись, которая выглядит иначе, но так же решает нашу задачу.
type BType = AType & {
third: string;
}
На этом можно было бы и закончить. Но не возник ли у вас вопрос, а можем ли мы применить ключевое слово extends к типу или & к интерфейсу? Давайте попробуем!
interface BInt extends AType {
third: string;
}
Внезапно! Эта запись работает! Получается, что интерфейсам в целом без разницы что расширять. Они одинаково хорошо расширяют как другой интерфейс, так и тип.
Но есть одно исключение. Если тип содержит union — extends сломается:
type AdminOrUser = { role: 'admin' } | { role: 'user' };
interface Ext extends AdminOrUser { // ❌ Ошибка: An interface can only extend an object type
extra: string;
}
type TExt = AdminOrUser & { extra: string }; // ✅ type с & справляется без проблем
Именно поэтому & для расширения типов гибче в общем случае — он не накладывает ограничений на форму того, что расширяет.
Важное отличие: конфликт полей
Но у гибкости & есть обратная сторона. Если при расширении возникает конфликт типов одного и того же поля, extends и & ведут себя по-разному:
interface A { x: number }
interface B extends A { x: string } // ❌ Ошибка — TypeScript сразу покажет конфликт
А вот & молча «пересечёт» типы, и поле станет never:
type A2 = { x: number }
type B2 = A2 & { x: string } // ✅ Компилируется, но x: number & string → never
const b: B2 = { x: ??? } // невозможно задать значение для x
extends защищает от таких багов на этапе объявления, тогда как & даёт гибкость, но требует внимательности.
Что же с инструментом пересечения?
type BType = AInt & {
third: string;
}
Да, он тоже прекрасно работает как с интерфейсами, так и типами.
Более того, оказывается, мы можем использовать в качестве базы еще одну сущность — класс.
Для примера создадим класс с двумя полями: first и second.
class AClass {
public first: boolean;
public second: number;
constructor(x: boolean, y: number) {
this.first = x;
this.second = y;
}
}
А теперь на его основе мы можем реализовать целевые тип и интерфейс:
interface BInt extends AClass {
third: string;
}
type BType = AClass & {
third: string;
}
Трудно поверить, но все эти инструменты и варианты записи дают нам идентичный результат.
Классы
Раз уж мы заговорили про классы, то давайте проясним и связанные с ними моменты.
Исторически так сложилось, что классы имплементируют, а простыми словами — реализуют, интерфейсы. Интерфейс является описанием, которому экземпляры класса должны соответствовать.
Сразу приведу пример: У нас есть уже наш давний знакомый интерфейс AInt, и его может имплементировать класс CClass. Выглядеть будет вот так:
// Описываем интерфейс
interface AInt {
first: boolean;
second: number;
}
// Имплементируем
class CClass implements AInt {
first = true;
second = 20;
}
// Создаем экземпляр
const x = new CClass();
console.log(x.first); // true
А теперь интересно, сможем ли мы создать объект с другими значениями, ведь мы зафиксировали конкретные значения first и second в конструкторе. Пробуем:
const a: CClass = {
first: false,
second: 15,
}
const b: AInt = {
first: false,
second: 15,
}
Да, все получилось! Класс в этом случае ведет себя так же как и интерфейс. Результаты идентичны.
А как поживает наш type? Думаю, вы уже догадались. Благодаря структурной типизации TypeScript позволяет использовать implements не только с interface, но и с type.
// Описываем тип
type AType = {
first: boolean;
second: number;
}
// Имплементируем
class CClass implements AType {
first = true;
second = 20;
}
// Создаем экземпляр
const x = new CClass();
console.log(x.first); // true
Более того, чтобы показать, что TypeScript не видит разницы не только в типах и интерфейсах, но и в классах, я воспользуюсь в качестве основы уже знакомым нам классом AClass:
// Описываем класс
class AClass {
public first: boolean;
public second: number;
constructor(x: boolean, y: number) {
this.first = x;
this.second = y;
}
}
// Имплементируем
class CClass implements AClass {
first = true;
second = 20;
}
// Создаем экземпляр
const x = new CClass();
console.log(x.first); // true
Обратите внимание: когда класс имплементирует другой класс, implements работает только с публичным контрактом — конструктор, приватные (private) и защищённые (protected) поля исходного класса игнорируются. То есть CClass в примере выше не наследует конструктор AClass, а лишь обязуется иметь поля first и second с совместимыми типами.
И здесь еще хочется добавить про расширение. Классы тоже умеют расширять типы, интерфейсы и другие классы. И да, у них тоже особенный синтаксис. Вот так можно добавить третье поле к AInt с помощью класса:
class BClass implements AInt {
public first: boolean;
public second: number;
public third: string;
constructor() {
this.first = true;
this.second = 1;
this.third = 'Hello';
}
}
Расширять, как вы поняли, можно так же и type, и class. Но это я позволю вам попробовать сделать самостоятельно.
Дженерики
Что такое дженерик? По факту — это инструмент языка TypeScript для описания структур данных, в которых тип каких-либо параметров структуры может задаваться извне. Дженерики в целом достойны отдельной статьи, здесь же разберем особенности в рамках сравнения типов и интерфейсов.
Возьмем привычные нам AInt и AType, назовем их DInt и DType соответственно, и сделаем так, чтобы тип параметра second можно было задавать.
// Определяем интерфейс
interface DInt<ExternalType> {
first: boolean;
second: ExternalType;
}
// Определяем тип
type DType<ExternalType> = {
first: boolean;
second: ExternalType;
}
Здесь мы создали переменную типа ExternalType. При создании константы с типом DInt или DType мы должны указывать тип, которому будет соответствовать параметр second.
Если его не передать — будет ошибка. Если фактическое значение не будет совпадать с указанным, то тоже будет ошибка. Посмотрите:
// Будет ошибка: Generic type 'DInt<ExternalType>' requires 1 type argument(s)
const dIntError: DInt = {
first: true,
second: 15,
}
// Так правильно
const dInt: DInt<number> = {
first: true,
second: 15,
}
// Так будет ошибка: Type 'string' is not assignable to type 'number'
const dTypeError: DType<number> = {
first: true,
second: 'bla bla',
}
// Так правильно
const dType: DType<string> = {
first: true,
second: 'bla bla',
}
На примерах видно, что определение и использование различается только на уровне определения ровно настолько, насколько в принципе различаются type и interface.
Расширение интерфейсов — Declaration Merging
Вот мы и добрались до одной из самых важных особенностей интерфейсов. Называется она Declaration Merging — слияние объявлений.
Суть простая: если вы объявите два интерфейса с одинаковым именем в одном скоупе, TypeScript не ругнется, а тихо сольёт их в один.
interface User {
name: string;
}
interface User {
age: number;
}
// TypeScript видит это как:
// interface User {
// name: string;
// age: number;
// }
const user: User = {
name: 'Иван',
age: 30,
}
С псевдонимами такой трюк не пройдёт:
type User = { name: string; }
type User = { age: number; } // ❌ Ошибка: Duplicate identifier 'User'
Зачем это нужно? Это мощный инструмент для расширения сторонних библиотек без изменения их исходников. Например, мы хотим добавить поле в глобальный объект Window:
// В нашем коде:
interface Window {
myPlugin: () => void;
}
// И теперь TypeScript знает про window.myPlugin
window.myPlugin = () => console.log('работает!');
Именно поэтому в публичных библиотеках принято экспортировать интерфейсы, а не псевдонимы — пользователь библиотеки сможет их расширить под свои нужды, не трогая чужой код.
Возможности псевдонимов
Мы уже разобрались, в чём интерфейс выигрывает у псевдонима. Теперь посмотрим на обратную сторону. У псевдонимов есть целый ряд вещей, которые интерфейс попросту не умеет.
Union types — объединение типов
Самая частая штука, с которой интерфейс не справится:
type Status = 'active' | 'inactive' | 'pending';
type ID = string | number;
Tuple — кортежи
Массив с фиксированным количеством элементов и конкретными типами для каждой позиции:
type Point = [number, number];
type Entry = [string, number]; // например, ['age', 30]
Технически можно определить кортеж через interface extends Array<T>, но нативного синтаксиса для этого нет, и на практике это неудобно — используйте type.
Mapped types — отображаемые типы
Позволяют создавать новые типы на основе существующих, проходясь по их ключам:
// Используем префикс My, чтобы не затенять встроенные Readonly<T> и Partial<T>
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
}
type MyOptional<T> = {
[K in keyof T]?: T[K];
}
Conditional types — условные типы
Тип, который зависит от условия — почти как тернарный оператор, но на уровне типов:
type IsString<T> = T extends string ? 'да' : 'нет';
type A = IsString<string>; // 'да'
type B = IsString<number>; // 'нет'
Все эти конструкции — только для псевдонимов. Попробуйте написать то же самое через interface — TypeScript немедленно возразит.
Index signature — это не то же самое, что Mapped type
Часто путают два похожих синтаксиса. У интерфейсов есть индексная подпись — способ сказать «любой ключ такого типа»:
interface Dictionary {
[key: string]: number; // любой строковый ключ → число
}
Это статическая маска: мы не знаем конкретных ключей и не вычисляем ничего. А вот mapped type — настоящая динамика: новый тип строится на основе ключей другого:
type User = { id: number; name: string };
// Строим новый тип, проходя по ключам User
type Flags<T> = { [K in keyof T]: boolean };
type UserFlags = Flags<User>; // { id: boolean; name: boolean }
Если попробовать mapped type через interface:
interface ReadonlyUser {
[K in keyof User]: User[K]; // ❌ A mapped type may not declare properties or methods
}
Таким образом, хотя синтаксис внешне похож, индексная подпись и mapped type — это принципиально разные вещи.
Итоги
Давайте подобьём всё, что разобрали, в одну таблицу:
| Возможность | type (псевдоним) | interface |
|---|---|---|
| Описание объекта | ✅ | ✅ |
Расширение через extends / & | ✅ | ✅ |
implements в классе | ✅ | ✅ |
| Дженерики | ✅ | ✅ |
| Declaration Merging | ❌ | ✅ |
| Extends union-типов | ✅ | ❌ |
| Union / Literal types | ✅ | ❌ |
| Tuple | ✅ | ❌ |
| Mapped types | ✅ | ❌ |
| Conditional types | ✅ | ❌ |
Из таблицы вытекает довольно простое правило:
Используй interface, когда описываешь контракт объекта, который должен быть расширяемым снаружи. Это прежде всего публичные API библиотек, экспортируемые типы, контракты для классов.
Используй type для всего остального: union-типы, кортежи, вычисляемые типы, пропсы React-компонентов, утилитарные типы. В большинстве прикладного кода именно псевдоним окажется более гибким инструментом.
Если не уверен — посмотри, нужна ли тебе возможность расширять тип снаружи. Нужна — interface. Нет — type.
Подписывайся на обновления в блоге в Telegram