type или interface: чем они правда отличаются
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Спор «type или interface» обычно ведут на вкус, а решается он фактами: их пять, и каждый проверяется одним запуском. Слияние объявлений, поведение при конфликте полей, неявная сигнатура индекса, то, что записывается только псевдонимом, и форма сообщения об ошибке — в остальном две записи взаимозаменяемы. Начнём с того, чего у них общего гораздо больше, чем различий.
Обе записи описывают форму объекта, и на значения это не влияет никак. Компилятор сравнивает типы по строению, а не по имени: если у значения есть нужные поля нужных типов, оно подходит — неважно, откуда взялось имя типа и было ли оно вообще.
Ни одной диагностики. distance ждёт Point, а получает значение, объявленное псевдонимом Coords; в обратную сторону тоже проходит. Ни interface, ни type не создают «новый тип», отличимый по имени, — разбору этого правила посвящена следующая статья главы.
Поэтому спор «что выбрать» и кажется вопросом вкуса. Но места, где выбор виден, есть. Первое ловит на ровном месте — на функции, которая принимает словарь.
Диагностика ровно одна, на последней строке: TS2345, «Аргумент типа "UserI" нельзя назначить параметру типа "Record<string, string>". В типе "UserI" отсутствует сигнатура индекса для типа "string"». Формы у UserI и UserT совпадают до буквы, а проходит только псевдоним.
Причина — в слиянии объявлений, с которого начинается следующий раздел. Интерфейс можно дописать в любом месте программы, поэтому компилятор не вправе заранее решить, что все его поля будут строками. У псевдонима набор полей закрыт в момент объявления, и неявную сигнатуру индекса компилятор выводит без риска. Сам Record — словарь с заранее неизвестными ключами; ему отведена последняя статья главы.
Первый практический вывод: если тип уезжает в чужую функцию, которая ждёт Record, псевдоним избавит вас от спора.
Два объявления интерфейса с одним именем — не ошибка и не опечатка. Компилятор складывает их поля в один тип.
Одна диагностика — на предпоследней строке: TS2741, «Свойство "y" отсутствует в типе "{ x: number; }" и является обязательным в типе "Point"». К моменту проверки Point — это { x: number; y: number }, и порядок объявлений не важен: первое ничего не знает о втором, но результат один.
Та же попытка с псевдонимом — просто ошибка.
Три диагностики: два TS2300 «Повторяющийся идентификатор "Point"» — по одной на каждое объявление — и TS2339 на последней строке, «Свойство "y" не существует в типе "Point"». Никакого сложения не произошло: победило первое объявление, второе просто отвергнуто. А выполнение всё равно случится и напечатает undefined — типы до браузера не доезжают.
Слияние — не курьёз, а приём, на котором держится расширение чужих типов. Библиотека объявила интерфейс, вы дописали в него своё поле, и оба объявления живут рядом.
Ни одной диагностики: наше объявление слилось с библиотечным, и last появился у всех массивов сразу. С псевдонимом такое невозможно в принципе — type Array<T> = ... даст всё тот же TS2300. Подробный разговор про расширение глобальных типов будет в статье «Расширение чужих типов: window, ImportMeta, слияние объявлений» из главы про границы программы.
Открытость — палка о двух концах: чужой пакет может дописать ваш интерфейс так же легко, как вы дописали Array.
Расширить готовый тип можно обеими записями, и в простом случае результат совпадает.
Записи не изолированы друг от друга: интерфейс может наследовать псевдоним объектного типа, а пересечение — включать интерфейс. Так что выбирать приходится не «во что расширять», а «чем записать расширение».
Расхождение появляется, когда новое поле конфликтует со старым. extends проверяет совместимость сразу, на месте объявления.
TS2430 на строке с extends: «Интерфейс "Extended" неправильно расширяет интерфейс "Base". Типы свойства "id" несовместимы. Тип "string" не может быть назначен для типа "number"». Сообщение в три уровня ведёт ровно туда, где вы ошиблись, — читать такие сообщения вы научились в статье про диагностику.
Пересечение на то же самое не жалуется. Оно вообще не проверяет совместимость: number & string — законный тип, просто в нём нет ни одного значения.
Обе попытки получили TS2322: «Тип "number" не может быть назначен для типа "never"» и «Тип "string" не может быть назначен для типа "never"». Поле id в Crossed имеет тип never — это и есть пересечение числа со строкой. Компилятор промолчал там, где ошибка написана, и заговорил там, где типом воспользовались; поэтому такую опечатку ищут дольше.
Пересечение полей — это пересечение множеств значений, а не «второе побеждает». Общих значений у number и string нет ни одного.
Ещё одно ограничение extends: справа от него должен стоять объектный тип. Объединение вариантов не подойдёт, даже если каждый вариант — объект.
TS2312: «Интерфейс может расширять только тип объекта или пересечение типов объектов со статическими известными членами». Пересечение здесь справляется — type Sized = Shape & { size: number } объявится без возражений и раздаст поле size обоим вариантам.
Интерфейс описывает объект. Всё остальное — территория псевдонима: у interface просто нет формы записи ни для объединения, ни для кортежа, ни для имени примитива. Попытка написать interface Status = "новый" | "готов" обрывается на разборе синтаксиса, ещё до проверки типов.
Ни одной диагностики, и заметьте: параметру status внутри report аннотация не понадобилась. Тип функции задан слева, поэтому компилятор вывел тип параметра сам — тот же контекстный вывод, что был в статье про наведение типов.
Псевдоним умеет ссылаться и сам на себя. Так описывают деревья и вообще любые данные неизвестной глубины.
Дальше по учебнику список того, что доступно только псевдониму, будет расти: keyof, индексный доступ, сопоставленные и условные типы записываются через type и никак иначе. Практическое следствие простое: как только тип становится хоть немного вычисляемым, интерфейс отпадает сам собой.
У интерфейса есть форма записи для вызова — сигнатура без имени. Выглядит непривычно, работает точно так же.
Смысл этой записи не в упрямстве, а в том, что рядом с сигнатурой вызова можно объявить поля: так описывают функцию, у которой есть свойства. Для обычного колбэка короче и понятнее псевдоним — о типах функций подробно поговорим в главе «Функции в типах», начиная со статьи «Тип функции: параметры, возврат, алиас для колбэка».
Расхожее утверждение: интерфейс компилятор показывает по имени, а псевдоним «разворачивает» в структуру, и потому сообщения про псевдонимы длиннее. Сегодня это неправда. Проверим на паре типов-близнецов.
Две диагностики TS2741, отличающиеся ровно одной буквой в имени: «...и является обязательным в типе "UserI"» и «...в типе "UserT"». Никакого разворачивания.
Настоящая разница видна на цепочке расширений. extends собирает один плоский тип с именем, пересечение остаётся списком слагаемых — и в сообщении всплывает слагаемое, а не имя, которое вы написали.
Первая диагностика короткая — TS2741, «Свойство "tags" отсутствует в типе "{ id: number; name: string; }" и является обязательным в типе "Full"». Вторая — TS2322 с вложенной причиной, и виноватым названо безымянное слагаемое { tags: string[]; }, а не FullT. Ошибка одна и та же, но искать по второму сообщению дольше: имени, по которому можно грепнуть, в нём нет.
Чем длиннее цепочка пересечений, тем дальше сообщение уходит от имени типа. На двух слагаемых это мелочь, на семи — уже нет.
Всё, что мы проверили, помещается в одну таблицу. Ни одна строка в ней не про «стиль» — каждая проверяется запуском.
| Вопрос | interface | type |
|---|---|---|
| Описать форму объекта | да | да |
| Объединение, кортеж, имя примитива | нельзя | да |
| Второе объявление с тем же именем | сливается с первым | 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 содержал оба поля, и объясните себе, почему первый вариант не работал.
Псевдоним объявляется один раз: два type с одним именем дают TS2300, второе объявление отбрасывается, и дальше сыплется всё, что опиралось на поле minutes. Самое короткое исправление — заменить обе записи интерфейсами: они сольются.
Если оба объявления ваши и лежат рядом, честнее собрать их в одно — слияние придумано для случая, когда исходное объявление править нельзя. Если же дописать нужно именно чужой тип, а он объявлен псевдонимом, слияние недоступно: остаётся пересечение под новым именем, type FullArticle = Article & { minutes: number }.
Пересечение, в которое ничего не кладётся
В отчёте идентификатор приходит строкой, а в исходном типе он число. Автор написал пересечение, и теперь ReportRow не принимает ни одного значения. Найдите тип поля id в диагностике и почините так, чтобы id был строкой, а title остался прежним.
Диагностика говорит прямо: TS2322, «Тип "string" не может быть назначен для типа "never"». Поле id — это number & string, то есть never: положить в него нечего. Значит, конфликтующее поле нужно не добавлять поверх, а исключить из основы, и тут пригодится Omit.
Разбор Omit и его родни ждёт в главе про типы, вычисляемые из значений; пока достаточно прочитать запись как «Product без поля id». Если типов всего два поля, ничуть не хуже описать ReportRow отдельно и не связывать его с Product вовсе — связь имеет смысл, только когда полей много и они меняются вместе.
Словарь не принимает интерфейс
Функция printAll написана не вами и ждёт Record<string, string>. Значение подходит по форме, но аргумент не проходит. Добейтесь чистой компиляции, не трогая printAll, — способов два, найдите оба.
Первый способ — превратить интерфейс в псевдоним: у него набор полей закрыт, и компилятор выведет сигнатуру индекса сам. Достаточно заменить interface Settings { на type Settings = { и поставить точку с запятой в конце.
Второй способ оставляет интерфейс, но снимает неопределённость явно: вы обещаете, что любое поле Settings — строка.
Оба варианта дают чистую компиляцию и один и тот же вывод: theme: dark и lang: ru. Выбор между ними — это выбор между «тип закрыт» и «тип открыт, но однороден»: со второй записью в Settings нельзя будет добавить поле-число. Синтаксис [key: string] разбирается в последней статье главы.