type или interface: чем они правда отличаются

объекты≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики. distance ждёт Point, а получает значение, объявленное псевдонимом Coords; в обратную сторону тоже проходит. Ни interface, ни type не создают «новый тип», отличимый по имени, — разбору этого правила посвящена следующая статья главы.

Поэтому спор «что выбрать» и кажется вопросом вкуса. Но места, где выбор виден, есть. Первое ловит на ровном месте — на функции, которая принимает словарь.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика ровно одна, на последней строке: TS2345, «Аргумент типа "UserI" нельзя назначить параметру типа "Record<string, string>". В типе "UserI" отсутствует сигнатура индекса для типа "string"». Формы у UserI и UserT совпадают до буквы, а проходит только псевдоним.

Причина — в слиянии объявлений, с которого начинается следующий раздел. Интерфейс можно дописать в любом месте программы, поэтому компилятор не вправе заранее решить, что все его поля будут строками. У псевдонима набор полей закрыт в момент объявления, и неявную сигнатуру индекса компилятор выводит без риска. Сам Record — словарь с заранее неизвестными ключами; ему отведена последняя статья главы.

Первый практический вывод: если тип уезжает в чужую функцию, которая ждёт Record, псевдоним избавит вас от спора.

Два объявления интерфейса с одним именем — не ошибка и не опечатка. Компилятор складывает их поля в один тип.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна диагностика — на предпоследней строке: TS2741, «Свойство "y" отсутствует в типе "{ x: number; }" и является обязательным в типе "Point"». К моменту проверки Point — это { x: number; y: number }, и порядок объявлений не важен: первое ничего не знает о втором, но результат один.

Та же попытка с псевдонимом — просто ошибка.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три диагностики: два TS2300 «Повторяющийся идентификатор "Point"» — по одной на каждое объявление — и TS2339 на последней строке, «Свойство "y" не существует в типе "Point"». Никакого сложения не произошло: победило первое объявление, второе просто отвергнуто. А выполнение всё равно случится и напечатает undefined — типы до браузера не доезжают.

Слияние — не курьёз, а приём, на котором держится расширение чужих типов. Библиотека объявила интерфейс, вы дописали в него своё поле, и оба объявления живут рядом.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики: наше объявление слилось с библиотечным, и last появился у всех массивов сразу. С псевдонимом такое невозможно в принципе — type Array<T> = ... даст всё тот же TS2300. Подробный разговор про расширение глобальных типов будет в статье «Расширение чужих типов: window, ImportMeta, слияние объявлений» из главы про границы программы.

Открытость — палка о двух концах: чужой пакет может дописать ваш интерфейс так же легко, как вы дописали Array.

Расширить готовый тип можно обеими записями, и в простом случае результат совпадает.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Записи не изолированы друг от друга: интерфейс может наследовать псевдоним объектного типа, а пересечение — включать интерфейс. Так что выбирать приходится не «во что расширять», а «чем записать расширение».

Расхождение появляется, когда новое поле конфликтует со старым. extends проверяет совместимость сразу, на месте объявления.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2430 на строке с extends: «Интерфейс "Extended" неправильно расширяет интерфейс "Base". Типы свойства "id" несовместимы. Тип "string" не может быть назначен для типа "number"». Сообщение в три уровня ведёт ровно туда, где вы ошиблись, — читать такие сообщения вы научились в статье про диагностику.

Пересечение на то же самое не жалуется. Оно вообще не проверяет совместимость: number & string — законный тип, просто в нём нет ни одного значения.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обе попытки получили TS2322: «Тип "number" не может быть назначен для типа "never"» и «Тип "string" не может быть назначен для типа "never"». Поле id в Crossed имеет тип never — это и есть пересечение числа со строкой. Компилятор промолчал там, где ошибка написана, и заговорил там, где типом воспользовались; поэтому такую опечатку ищут дольше.

Пересечение полей — это пересечение множеств значений, а не «второе побеждает». Общих значений у number и string нет ни одного.

Ещё одно ограничение extends: справа от него должен стоять объектный тип. Объединение вариантов не подойдёт, даже если каждый вариант — объект.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS2312: «Интерфейс может расширять только тип объекта или пересечение типов объектов со статическими известными членами». Пересечение здесь справляется — type Sized = Shape & { size: number } объявится без возражений и раздаст поле size обоим вариантам.

Интерфейс описывает объект. Всё остальное — территория псевдонима: у interface просто нет формы записи ни для объединения, ни для кортежа, ни для имени примитива. Попытка написать interface Status = "новый" | "готов" обрывается на разборе синтаксиса, ещё до проверки типов.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики, и заметьте: параметру status внутри report аннотация не понадобилась. Тип функции задан слева, поэтому компилятор вывел тип параметра сам — тот же контекстный вывод, что был в статье про наведение типов.

Псевдоним умеет ссылаться и сам на себя. Так описывают деревья и вообще любые данные неизвестной глубины.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Дальше по учебнику список того, что доступно только псевдониму, будет расти: keyof, индексный доступ, сопоставленные и условные типы записываются через type и никак иначе. Практическое следствие простое: как только тип становится хоть немного вычисляемым, интерфейс отпадает сам собой.

У интерфейса есть форма записи для вызова — сигнатура без имени. Выглядит непривычно, работает точно так же.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Смысл этой записи не в упрямстве, а в том, что рядом с сигнатурой вызова можно объявить поля: так описывают функцию, у которой есть свойства. Для обычного колбэка короче и понятнее псевдоним — о типах функций подробно поговорим в главе «Функции в типах», начиная со статьи «Тип функции: параметры, возврат, алиас для колбэка».

Расхожее утверждение: интерфейс компилятор показывает по имени, а псевдоним «разворачивает» в структуру, и потому сообщения про псевдонимы длиннее. Сегодня это неправда. Проверим на паре типов-близнецов.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две диагностики TS2741, отличающиеся ровно одной буквой в имени: «...и является обязательным в типе "UserI"» и «...в типе "UserT"». Никакого разворачивания.

Настоящая разница видна на цепочке расширений. extends собирает один плоский тип с именем, пересечение остаётся списком слагаемых — и в сообщении всплывает слагаемое, а не имя, которое вы написали.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая диагностика короткая — TS2741, «Свойство "tags" отсутствует в типе "{ id: number; name: string; }" и является обязательным в типе "Full"». Вторая — TS2322 с вложенной причиной, и виноватым названо безымянное слагаемое { tags: string[]; }, а не FullT. Ошибка одна и та же, но искать по второму сообщению дольше: имени, по которому можно грепнуть, в нём нет.

Чем длиннее цепочка пересечений, тем дальше сообщение уходит от имени типа. На двух слагаемых это мелочь, на семи — уже нет.

Всё, что мы проверили, помещается в одну таблицу. Ни одна строка в ней не про «стиль» — каждая проверяется запуском.

Вопросinterfacetype
Описать форму объектадада
Объединение, кортеж, имя примитиванельзяда
Второе объявление с тем же именемсливается с первымTS2300
Запись расширенияextends&
Конфликт полей при расширенииTS2430 на месте объявленияполе становится never, ошибка при использовании
Передать в Record<string, string>TS2345: нет сигнатуры индексапроходит
keyof, сопоставленные и условные типынельзяда

Правило, которое переживает споры в команде: форму объекта, которую кто-то будет расширять или которую вы отдаёте наружу как часть публичного интерфейса модуля, пишите через interface; всё остальное — через type. И главное: не переписывайте чужой файл ради единообразия. Разница проявляется в шести строках таблицы, а не «вообще везде».

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

Порога руководство не называет, и на глаз его не поймать. Практическое правило проще: если tsc у вас думает минуту, длинные цепочки & стоит поискать; если секунду — выбирайте по таблице выше, а не по скорости.

  • Обе записи описывают форму объекта одинаково: сравнение идёт по строению, поэтому значение с псевдонимом свободно проходит туда, где ждут интерфейс.
  • Одноимённые интерфейсы сливаются в один тип — на этом держится расширение чужих объявлений; два type с одним именем дают TS2300.
  • Из-за открытости интерфейса компилятор не выводит для него неявную сигнатуру индекса: в Record<string, string> проходит псевдоним, а интерфейс получает TS2345.
  • extends проверяет совместимость сразу и даёт TS2430 в месте объявления; пересечение молчит, превращает конфликтующее поле в never и роняет код при использовании.
  • Объединения, кортежи, имена примитивов, рекурсия и все вычисляемые типы записываются только через type; у интерфейса для них нет синтаксиса.
  • Миф про «разворачивание» псевдонима в диагностике не подтверждается: имя показывается у обоих. Зато цепочка пересечений в сообщении распадается на безымянные слагаемые, а цепочка extends — нет.

1/4В одном файле дважды написано interface Point: сначала с полем x, потом с полем y. Что скажет компилятор?

2/4Написано type Row = { id: number } & { id: string }. Где будет ошибка?

3/4Интерфейс и псевдоним описывают одну и ту же форму { name: string }. Оба значения передают в функцию с параметром Record<string, string>. Что произойдёт?

4/4Что из перечисленного нельзя записать через interface?

Дописать тип, который уже объявлен

Автор пытался дополнить тип вторым объявлением — как делают с интерфейсами. Получилось четыре диагностики. Почините код так, чтобы Article содержал оба поля, и объясните себе, почему первый вариант не работал.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Псевдоним объявляется один раз: два type с одним именем дают TS2300, второе объявление отбрасывается, и дальше сыплется всё, что опиралось на поле minutes. Самое короткое исправление — заменить обе записи интерфейсами: они сольются.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Если оба объявления ваши и лежат рядом, честнее собрать их в одно — слияние придумано для случая, когда исходное объявление править нельзя. Если же дописать нужно именно чужой тип, а он объявлен псевдонимом, слияние недоступно: остаётся пересечение под новым именем, type FullArticle = Article & { minutes: number }.

Пересечение, в которое ничего не кладётся

В отчёте идентификатор приходит строкой, а в исходном типе он число. Автор написал пересечение, и теперь ReportRow не принимает ни одного значения. Найдите тип поля id в диагностике и почините так, чтобы id был строкой, а title остался прежним.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика говорит прямо: TS2322, «Тип "string" не может быть назначен для типа "never"». Поле id — это number & string, то есть never: положить в него нечего. Значит, конфликтующее поле нужно не добавлять поверх, а исключить из основы, и тут пригодится Omit.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Разбор Omit и его родни ждёт в главе про типы, вычисляемые из значений; пока достаточно прочитать запись как «Product без поля id». Если типов всего два поля, ничуть не хуже описать ReportRow отдельно и не связывать его с Product вовсе — связь имеет смысл, только когда полей много и они меняются вместе.

Словарь не принимает интерфейс

Функция printAll написана не вами и ждёт Record<string, string>. Значение подходит по форме, но аргумент не проходит. Добейтесь чистой компиляции, не трогая printAll, — способов два, найдите оба.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый способ — превратить интерфейс в псевдоним: у него набор полей закрыт, и компилятор выведет сигнатуру индекса сам. Достаточно заменить interface Settings { на type Settings = { и поставить точку с запятой в конце.

Второй способ оставляет интерфейс, но снимает неопределённость явно: вы обещаете, что любое поле Settings — строка.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Оба варианта дают чистую компиляцию и один и тот же вывод: theme: dark и lang: ru. Выбор между ними — это выбор между «тип закрыт» и «тип открыт, но однороден»: со второй записью в Settings нельзя будет добавить поле-число. Синтаксис [key: string] разбирается в последней статье главы.