Глава 14. Интерфейсы и type
Глава 14. Интерфейсы и type
Интерфейс, в который не надо вступать
Объявим интерфейс и объект, который ему как будто соответствует, — но без единого слова о том, что объект как-то с этим интерфейсом связан.
interface Order {
id: string;
total: number;
}
const o = { id: "A-1", total: 500 };
function ship(order: Order): void {
console.log(`Отгружаю ${order.id}`);
}
ship(o); // компилируется. ошибок нет
o — обычный объектный литерал. Нигде не написано, что он Order. Нет ни implements, ни приведения, ни регистрации, ни какого-либо акта вступления. И всё же ship(o) проходит проверку без замечаний, потому что у o есть поле id типа string и поле total типа number — ровно то, чего требует форма Order. Совпала форма — значит, значение подходит. Этого достаточно.
Для того, кто пришёл из мира, где интерфейс — это контракт, а соответствие контракту достигается явным implements, здесь происходит что-то не то. Там объект был бы Order, только если бы его класс объявил implements Order; иначе — посторонний, которого к функции не подпустят. Здесь же объект становится Order молча, задним числом, просто фактом своей формы. Никто не спрашивал его согласия.
Дальше — сильнее. Объявим интерфейс с тем же именем дважды:
interface Box {
width: number;
}
interface Box {
height: number;
}
const b: Box = { width: 10, height: 20 }; // оба поля обязательны
const bad: Box = { width: 10 };
// Ошибка: в типе '{ width: number; }' отсутствует свойство 'height',
// обязательное в типе 'Box'
Два объявления Box не конфликтуют и не перекрывают друг друга — они сливаются в один тип с обоими полями. Сделайте то же самое через type, и компилятор остановит вас:
type Cup = { width: number };
type Cup = { height: number };
// Ошибка: повторяющийся идентификатор 'Cup'
И последнее наблюдение, прежде чем разбираться. Интерфейс отказывается описывать то, что type описывает одной строкой:
type Status = "created" | "paid" | "shipped"; // объединение — норма
interface IStatus = "created" | "paid"; // так нельзя
// Ошибка: ожидается '{'
// (после имени интерфейса компилятор ждёт тело в фигурных скобках, а не '=')
Три факта, которые не складываются в привычную картину. Объект удовлетворяет интерфейсу без implements. Два одноимённых интерфейса молча сливаются. Интерфейс не умеет выразить A | B, а type умеет. Значит, interface и type — не два синонима с косметической разницей и не «класс без реализации». Это два инструмента с разными возможностями, и оба описывают не контракт, в который вступают, а форму, которой соответствуют. Разберёмся, что есть что и когда брать который.
Оба описывают форму, но type умеет больше
Начнём с того, в чём они и правда почти неразличимы, — с объектных форм. Опишем заказ обоими способами и убедимся, что для потребителя разницы нет.
interface OrderI {
id: string;
total: number;
}
type OrderT = {
id: string;
total: number;
};
const a: OrderI = { id: "A-1", total: 500 };
const c: OrderT = a; // присваивается без вопросов
a объявлен как OrderI, но спокойно ложится в переменную типа OrderT. Проверяющий не сверяет имена — он сверяет формы, а формы совпадают до поля. Для функции, которая принимает OrderT, значение типа OrderI неотличимо от своего, и наоборот. На уровне объектных форм выбор между ними — почти вопрос вкуса.
Почти — потому что граница проходит не здесь. type — это алиас (псевдоним): имя, которое можно привязать к любому типовому выражению, какое язык вообще способен записать. Не только к объектной форме.
type OrderId = string; // алиас примитива
type Status = "created" | "paid" | "shipped"; // объединение (union)
type Pair = [number, number]; // кортеж (tuple)
type Handler = (order: OrderT) => void; // тип функции
type Keys = keyof OrderT; // вычисляемый тип: "id" | "total"
union здесь — тип-сумма, «или одно, или другое»; tuple — массив фиксированной длины с типом на каждой позиции. Ни одно из этих пяти объявлений нельзя переписать через interface, потому что interface описывает не «любой тип», а узкий список форм: форму объекта, форму функции и форму конструктора. Вне этого списка он не работает.
Форму функции и конструктора интерфейс, кстати, выражает — через сигнатуру вызова и new:
interface Handler {
(order: OrderT): void; // сигнатура вызова: интерфейс описывает функцию
}
interface OrderFactory {
new (id: string): OrderT; // сигнатура конструктора
}
Это работает и иногда читается лучше алиаса. Но union, tuple, keyof, условные и сопоставленные типы — всё, что не сводится к «форме объекта или вызываемого» — для интерфейса недоступно. Отсюда первое практическое правило, ещё без всякой философии: если то, что вы описываете, — не объектная форма, выбора нет, берётся type. Если объектная — выбор есть, и решает его уже другое свойство.
Слияние объявлений — суперсила интерфейса
Это другое свойство — то самое молчаливое слияние, которое в начале выглядело как ловушка. У interface есть способность, которой у type нет в принципе: два объявления с одним именем в одной области сливаются в одно.
interface Order {
id: string;
}
interface Order {
total: number;
}
// дальше Order — это { id: string; total: number }
const o: Order = { id: "A-1", total: 500 };
Это называется declaration merging — слияние объявлений. Сам по себе приём внутри одного файла выглядит сомнительно: зачем дробить тип на куски, когда можно написать его целиком. Ценность не в этом. Ценность в том, что слиться могут объявления из разных мест — в том числе ваше с чужим, которое вы не контролируете.
На этом стоит расширение типов библиотек. Допустим, библиотека объявила глобальный интерфейс, а вам нужно дописать в него поле — потому что плагин этой библиотеки кладёт туда своё. С type вы бессильны: чужой алиас закрыт, переопределить его нельзя. С interface вы открываете объявление заново и добавляете то, что нужно:
// объявлено где-то в библиотеке:
interface RequestContext {
url: string;
}
// ваш код, в другом файле — дополняет, а не переопределяет:
interface RequestContext {
userId: string;
}
function handle(ctx: RequestContext) {
console.log(ctx.url, ctx.userId); // оба поля видны, оба обязательны
}
Тот же механизм лежит в основе расширения встроенных глобальных типов вроде Window — когда вы навешиваете на window своё свойство и хотите, чтобы проверяющий о нём знал. Интерфейс Window уже объявлен в стандартной библиотеке; вы дописываете в него своё поле новым одноимённым объявлением, и тип расширяется, а не ломается. Подробный разбор того, как это оформляется для модулей и глобальной области, — в главе 28; здесь важен сам факт: открытость интерфейса к дополнению — это возможность, которой у закрытого type нет.
У открытости есть оборотная сторона, и о ней честно сразу. Слияние срабатывает всегда, когда имена совпали, — не только когда вы этого хотели. Два инженера в большой кодовой базе независимо объявляют interface Options, оба в глобальной области, — и компилятор не ругается на дубликат, а молча сваривает их в один тип с полями обоих. Никакой ошибки; просто Options внезапно требует поля, о которых ни один из авторов не подозревал. С type такого не случится никогда — повторное имя там немедленная ошибка, как мы видели с Cup. Слияние — это сила ровно в той же мере, в какой и риск: инструмент, который надо включать осознанно, а не получать в подарок от случайного совпадения имён.
extends против &
Объектные формы можно комбинировать обоими инструментами, но комбинируют они по-разному, и разница не косметическая. Интерфейс расширяют через extends; типы складывают через & — intersection (пересечение), тип «и то, и другое сразу». Пока поля не конфликтуют, результат одинаков:
interface Base {
id: string;
}
interface Timestamped extends Base {
createdAt: number;
}
// Timestamped = { id: string; createdAt: number }
type BaseT = { id: string };
type TimestampedT = BaseT & { createdAt: number };
// то же самое
Разница вылезает на конфликте. Объявим два источника, которые приписывают одному и тому же полю несовместимые типы, и посмотрим, как себя поведут оба способа.
interface A {
total: number;
}
interface B extends A {
total: string; // конфликт с A.total
}
// Ошибка: интерфейс 'B' некорректно расширяет интерфейс 'A'.
// Типы свойства 'total' несовместимы.
// Тип 'string' не присваивается типу 'number'.
interface extends проверяет совместимость в момент объявления и громко останавливается: вы пытаетесь сузить number до string, это противоречие, исправляйте. Теперь то же самое через пересечение:
type AT = { total: number };
type BT = AT & { total: string };
declare const x: BT;
const t = x.total; // тип: never
Ни единой ошибки на объявлении. & не проверяет совместимость — он честно пересекает множества значений (модель «типы как множества» — из главы 6, пересечение как операция над ними — из главы 7). Множество чисел и множество строк не пересекаются нигде, и тип поля total становится never — пустым множеством, типом, у которого нет ни одного значения. Ошибки нет до тех пор, пока вы не попробуете этому полю что-нибудь присвоить, — а тогда выяснится, что присвоить нельзя ничего, и never всплывёт в каком-нибудь отдалённом месте, далеко от причины.
Отсюда расхожее наблюдение о сообщениях компилятора, и его стоит передать без преувеличения. Интерфейс именует себя: в ошибке и в подсказке редактора вы чаще увидите Order, а не развёрнутую структуру. Пересечение анонимно — большой A & B & C & D компилятор нередко раскрывает в полотно полей, и сообщение об ошибке распухает. Есть и аргумент про производительность проверки: именованный интерфейс с extends компилятор кэширует по имени, тогда как глубокие цепочки пересечений он пересчитывает заново. На обычной кодовой базе разницы вы не заметите; она становится ощутимой на очень больших и сложных типах. Это не повод объявлять interface всегда — это одна гирька на весах, не более.
implements — это галочка, а не членство
Остался implements — слово, из-за которого вся путаница и начинается. Класс может объявить, что реализует интерфейс. Посмотрим, что именно это объявление делает, а главное — чего не делает.
interface HasTotal {
total: number;
}
class Cart implements HasTotal {
total = 0;
add(price: number) {
this.total += price;
}
}
implements HasTotal здесь — проверка, и только проверка. Компилятор сверяет: есть ли у Cart поле total типа number? Есть. Галочка поставлена, объявление прошло. Если бы поля не было или оно было бы строкой — была бы ошибка ровно в этой точке, на классе. На этом роль implements заканчивается. Он ничего не добавляет в класс, не приносит реализаций, не оставляет следа в значениях.
Доказательство — в том, что implements вообще не нужен, чтобы экземпляр Cart считался HasTotal. Уберём его и проверим:
class Cart {
total = 0; // никакого implements
}
function report(x: HasTotal): void {
console.log(x.total);
}
report(new Cart()); // компилируется
Экземпляр Cart подходит под HasTotal по той же причине, по которой в начале главы объектный литерал подходил под Order: совпала форма. implements не делает класс «членом» интерфейса — членства тут нет вообще. Он лишь заставляет компилятор проверить класс на соответствие форме в месте объявления класса, чтобы вы поймали расхождение раньше и ближе к причине. Это страховка для автора класса, а не пропуск, который класс предъявляет на входе.
И завершающий гвоздь: интерфейс стирается. После компиляции от HasTotal не остаётся ни байта — никакого объекта, никакой рантайм-сущности (механизм стирания — глава 3). Поэтому спросить у значения, «реализует ли оно интерфейс», в рантайме невозможно:
if (x instanceof HasTotal) {} // ошибка: 'HasTotal' — это тип, а не значение
Спрашивать не у кого. instanceof проверяет цепочку прототипов реального объекта, а интерфейс реальным объектом не становится никогда. То, что в Java рутинная операция, здесь не запрещено из вредности — его просто нечем выполнить.
Глазами джависта
Слово
implementsпереехало в TypeScript из Java вместе с написанием, но со ровно противоположным смыслом, и в этом корень всех неверных интуиций.Интерфейс Java — номинативный контракт. Класс является
Runnable, только если он написалimplements Runnable; без этой записи он неRunnable, сколько бы методовrun()у него ни было. Отношение IS-A устанавливается именем и фактом объявления, а не формой. Подружить с интерфейсом уже существующий чужой тип задним числом нельзя — его исходник вам не принадлежит; приходится оборачивать его в адаптер, которыйimplementsобъявит за него. Интерфейс при этом несёт поведение:default-методы кладут реализацию прямо в контракт. И он реален в рантайме — JVM помнит, какие интерфейсы реализует объект, поэтомуobj instanceof Runnableработает и(Runnable) objимеет смысл.Интерфейс TypeScript — структурная форма. Значение удовлетворяет ему по виду, без объявления, и удовлетворяет ретроактивно: чужой объект, написанный за годы до вашего интерфейса и ничего о нём не знающий, подойдёт, если совпала форма, — никаких адаптеров. Поведения интерфейс не несёт, только форму. В рантайме его нет вовсе,
instanceofпо нему невозможен. И слияния одноимённых объявлений, на котором тут стоит расширение библиотек, в Java нет — дваinterface Fooтам были бы ошибкой компиляции.Одно слово, обратный вектор. В Java
implementsотвечает на вопрос «как мне стать этим типом» — это акт вступления. В TypeScriptimplementsотвечает на вопрос «совпадает ли моя форма с этой» — это галочка самопроверки, без которой соответствие всё равно бы состоялось. Кто читает второе как первое, ищет в TypeScript членство, которого там нет.
Где выбор кусается
Теперь честный список мест, где разница между interface и type оборачивается граблями.
Слияние срабатывает там, где его не звали. Открытость интерфейса к дополнению — то же свойство, что и случайная сварка двух одноимённых объявлений. В глобальной области или внутри одного модуля совпадение имён не даёт ошибки, а молча объединяет типы, и поле приходит словно ниоткуда. type в этом смысле безопаснее ровно потому, что закрыт: повторное имя — немедленная ошибка, без сюрпризов.
interface упирается в потолок выразительности. Как только форма перестаёт быть объектной — нужно объединение, кортеж, тип функции как алиас, что-нибудь вычисляемое через keyof или сопоставленный тип — интерфейс заканчивается, и переписывать приходится на type. Если вы строили иерархию интерфейсов и вдруг понадобилось Order | ArchivedOrder, выразить это в терминах интерфейсов не выйдет — тупик, разворот к алиасам.
& прячет конфликт, extends его кричит. Мы это видели: пересечение несовместимых полей даёт never молча, и беда всплывает позже и в стороне от причины, тогда как interface extends ругается прямо на объявлении. Складывая формы через &, держите в голове, что компилятор не предупредит о противоречии, — он просто схлопнет поле в never и будет ждать, пока вы об него споткнётесь.
implements даёт ложное чувство защищённости. Он проверяет класс на соответствие форме — и только. Он не метит экземпляры рантайм-биркой и не превращает интерфейс в фильтр на входе: код вокруг всё равно работает с типом структурно, и любое значение нужной формы пройдёт туда независимо от того, объявлял его класс implements или нет. Принимать implements за гарантию принадлежности — значит ждать от него того, чего он не делает.
Большие пересечения дают чудовищные сообщения. Анонимный A & B & C & D с конфликтом где-то в глубине компилятор разворачивает в простыню полей, и найти в ней, что именно не сошлось, тяжело. Именованные интерфейсы в таких сообщениях остаются именами и читаются легче. На сложных типах это реальная, а не выдуманная разница в стоимости отладки.
Ни один из этих пунктов не делает один инструмент «правильным», а другой «неправильным». Это карта того, где каждый из них режет, — чтобы выбирать с открытыми глазами.
Выбирай по возможности, не по догме — и ни то ни другое не контракт Java
Вот переключатель, который должен щёлкнуть.
Перестаньте искать в interface и type контракт, в который значение вступает. Контракта нет ни в одном из них. Оба — способы записать форму, которой значение удовлетворяет по виду, без объявления, ретроактивно и без всякого членства. Объект подходит, потому что у него совпали поля, — а не потому, что он где-то расписался. implements этого не меняет: он флажок самопроверки на классе, а не акт вступления и не рантайм-бирка. С этой точки зрения вопрос «interface или type» перестаёт быть вопросом веры и становится вопросом возможностей.
И решается он коротко. Нужна вся алгебра типов — объединение, пересечение как осознанный приём, кортеж, функция-алиас, что-нибудь вычисляемое — берётся type, потому что interface это просто не выразит. Нужна объектная форма, которую вы намеренно оставляете открытой для дополнения — расширение библиотеки, глобальные типы, публичный API, куда другие будут дописывать, — или хочется более чистых ошибок и подсказок по имени, берётся interface. Объектная форма без этих требований — берите что привычнее команде и держитесь одного выбора; разница здесь действительно вопрос вкуса.
Слиянием пользуйтесь как инструментом, а не получайте его как побочный эффект совпадения имён: включайте осознанно там, где расширяете чужой тип, и сторонитесь там, где два имени могли столкнуться случайно. И ни при каких обстоятельствах не мыслите ни interface, ни type номинативным контрактом. Это две записи одной и той же сущности — формы значения, — и выбор между ними диктуется тем, что каждая запись умеет, а не догмой о том, что «интерфейс — это контракт».
Итог
interface и type — два способа записать форму, выбираемые по возможностям: type владеет всей алгеброй и закрыт, interface умеет сливаться и оставаться открытым. Кто перестал видеть в них контракт, в который вступают через implements, тот выбирает инструмент под задачу, а не исповедует догму.