← Глава 13. Функции как граждане первого класса Содержание Глава 15. Классы в структурном мире →

Глава 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 отвечает на вопрос «как мне стать этим типом» — это акт вступления. В TypeScript implements отвечает на вопрос «совпадает ли моя форма с этой» — это галочка самопроверки, без которой соответствие всё равно бы состоялось. Кто читает второе как первое, ищет в 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, тот выбирает инструмент под задачу, а не исповедует догму.

← Глава 13. Функции как граждане первого класса Содержание Глава 15. Классы в структурном мире →