implements, abstract и приватность на уровне типов

классы≈ 11 минут

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

Структурная типизация уже показала: класс подходит под интерфейс, даже если никогда о нём не слышал. Зачем тогда implements — и что он на самом деле делает с классом? А заодно разберёмся, почему поле под private спокойно уезжает в JSON.stringify, а #поле из части I — нет.

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

Раз тип — это форма, а не имя, класс подходит под интерфейс сам собой, ни у кого не спрашиваясь. Значит, implements никуда не пускает и ничего не открывает. Он делает другое: переносит ошибку туда, где её проще чинить. Посмотрите, где компилятор ругается на класс с опечаткой в имени метода.

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

Диагностика прилетает в последнюю строку: Аргумент типа "Button" нельзя назначить параметру типа "Clickable", причина — Свойство "onClick" отсутствует в типе "Button". Класс объявлен выше и выглядит невинно, а виновата в нём одна буква. В большом файле такой поиск занимает минуты; в чужом коде — больше.

Теперь допишем одно слово в объявление класса — и ничего больше.

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

Диагностик стало две. Прежняя осталась — форма-то не изменилась. Но добавилась новая, ровно на строке с class: Класс "Button" неправильно реализует интерфейс "Clickable", и та же вложенная причина про пропавший onClick. Это и есть весь смысл implements: примерка контракта прямо в месте объявления.

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

А вот чего implements не делает — так это не раздаёт типы. Слово стоит рядом, интерфейс объявлен выше, параметр метода назван так же, и всё равно:

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

Параметр "message" неявно имеет тип "any" — знакомая TS7006 из главы про базовые типы. Контекстная типизация, которая выручает в колбэках, на методы класса не распространяется: тело класса компилятор читает само по себе, а потом сверяет получившееся с интерфейсом. Сверка идёт в одну сторону — от класса к контракту, не обратно. Типы параметров придётся выписать руками.

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

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

TS2416: Свойство "drive" в типе "Bike" невозможно присвоить тому же свойству в базовом типе "Vehicle" → Тип "(distance: number) => number" не может быть назначен для типа "(distance: number) => string" → Тип "number" не может быть назначен для типа "string". Три уровня, и последний — тот самый, который надо чинить. Читается так же, как в статье про вложенные причины: сверху вниз, до самой конкретной строки.

Интерфейс умеет только требовать. Абстрактный класс требует и одновременно даёт готовый код: часть методов написана, часть помечена abstract и оставлена наследнику. Классический приём — общий алгоритм в базе, а разница вынесена в дыры.

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

Ни одной диагностики. describe вызывает this.area(), которого в Shape нет, — и это законно: abstract обещает, что у любого настоящего экземпляра метод будет. Обещание держится ровно одним запретом.

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

TS2511: Невозможно создать экземпляр абстрактного класса. А дальше — то, ради чего этот пример и написан: код всё равно выполняется и падает по-настоящему. TypeError: this.area is not a function.

Причина в том, что abstract — чистая выдумка TypeScript. В браузер уезжает обыкновенный class Shape без единого следа абстрактности, и new для него — законная операция. Запрет существует только на этапе проверки; проигнорировали диагностику — получили объект с дырой вместо метода.

Абстрактный класс годится как тип: массив Shape[] выше собран без единой жалобы. Нельзя вызвать new — всё остальное можно.

Если наследник забудет закрыть дыру, компилятор скажет об этом на объявлении наследника, а не при первом вызове. Точные слова — в задаче в конце статьи, а пока сравним два инструмента.

interfaceабстрактный класс
Готовый код внутринет, только требованияда, обычные методы и поля
Сколько можно взятьсколько угодно: implements A, Bодин: extends
Доживает до браузеранет, стирается целикомда, это обычный класс
Создать экземплярнечего создаватьнельзя — TS2511
Контракт не выполненTS2420 на строке классаTS2515 на строке класса

extends и implements не конфликтуют: класс наследует реализацию у одного родителя и параллельно расписывается под сколькими угодно контрактами. Порядок слов фиксированный — сначала extends.

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

И заодно видно, что implements берёт не только интерфейсы: слева от запятой стоит псевдоним типа Named. Компилятору всё равно, как назван объектный тип.

В JavaScript у поля есть ровно два состояния: обычное и #приватное. TypeScript добавляет к этому три слова, которые расставляют доступ мягче: public (по умолчанию, писать не обязательно), protected — «я и наследники», private — «только я».

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

Две диагностики, каждая называет виновника поимённо: Свойство "balance" является закрытым и доступно только в классе "Account" и Свойство "owner" является защищенным и доступно только в классе "Account" и его подклассах. И — привычное уже — обе строки всё равно печатают значения. Запрет живёт до сборки, данные живут дольше.

Насколько «дольше», лучше видно на голом примере. Предскажите три строки вывода, прежде чем нажимать Run.

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

Ни одной диагностики — и это не опечатка в статье. Первые две строки понятны: private стирается при компиляции, в браузер уезжает обычное поле, и любой перебор его находит. Третья строка удивительнее: обращение через квадратные скобки компилятор пропускает молча. Доступ по строковому ключу компилятор на закрытость не проверяет — известная лазейка, которой пользуются в тестах, когда нужно заглянуть внутрь чужого класса.

private — это договор между вами и компилятором, а не защита данных. Никакой приватности в собранном коде от него не остаётся.

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

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

TS2420: Класс "Bus" неправильно реализует интерфейс "Vehicle" → Свойство "wheels" является закрытым в типе "Bus" и не является таковым в типе "Vehicle". Уровень доступа — часть формы, а не украшение поверх неё.

Здесь начинается самое неожиданное. Правило «совпал состав — совпал тип» вы знаете из главы про структурную типизацию: два класса с полями x и y взаимозаменяемы, даже если ничего друг о друге не знают. Добавьте в оба по одному private — и правило перестаёт работать.

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

Тип "Seconds" не может быть назначен для типа "Meters" → Типы имеют раздельные объявления закрытого свойства "value". Ключевое слово — раздельные. Компилятор сравнивает не имена и не типы полей, а место, где поле объявлено: два разных private value — это два разных поля, сколько бы они ни были похожи.

Проверить эту формулировку легко: если объявление одно на двоих, совместимость возвращается.

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

Первое присваивание проходит, второе — нет. Такое поведение называют номинальной типизацией: тип определяется происхождением, а не формой. В TypeScript её нет как отдельной возможности, но одно закрытое поле включает её для конкретного класса.

Отсюда приём «брендирования»: закрытое поле-пустышка не даёт перепутать Метры с Секундами, хотя внутри у обоих одно число.

Если в примере убрать оба слова private, обе диагностики исчезнут — классы снова станут просто «объектами без полей», а такие подходят куда угодно. Попробуйте, это одно слово.

Про #поле вы читали в главе про классы: это часть самого JavaScript, и живёт оно до конца — в собранном коде, в браузере, во время выполнения. Разница между ним и private видна в четырёх строках.

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

Компилятор молчит на всех четырёх строках, а результаты противоположные. Поле под private сериализуется, перебирается и достаётся скобками. Поле с решёткой не видно ни в JSON.stringify, ни в Object.keys — и достать его снаружи нельзя вообще никак.

«Никак» — это буквально. Обращение к чужому #полю ловится дважды.

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

Сначала компилятор: TS18013: Свойство "#balance" недоступно вне класса "Wallet", так как оно имеет закрытый идентификатор. Потом — сам движок: SyntaxError: Private field '#balance' must be declared in an enclosing class. Не undefined, не TypeError при вызове, а отказ разобрать код: строка не запускается вообще, вместе со всем файлом. Из всех примеров этой статьи только здесь ошибка типов совпала с ошибкой языка.

private из TypeScript#поле из JavaScript
Кто проверяеттолько компиляторкомпилятор и движок
Что в собранном кодеобычное поленастоящее приватное поле
JSON.stringify, Object.keysпоказываютне видят
Обход через obj["имя"]работает, диагностики нетневозможен
Доступ из наследниканет (для этого protected)нет, и обойти нельзя
Годится для секретанетда

Выбор из этого выходит несложный. Нужно спрятать реализацию от коллеги и получить подсказку в редакторе — хватит private: он короче, работает с protected и понятен по всей иерархии. Нужно, чтобы значение действительно нельзя было прочитать снаружи — токен, ключ, внутренний кеш, — только решётка.

Эффект из прошлого раздела у #поля ровно такой же, только формулировка причины другая.

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

Свойство "#value" в типе "Seconds" ссылается на другой член, к которому невозможно обратиться из типа "Meters". Здесь это уже не выдумка компилятора: два #value — действительно разные поля и во время выполнения.

  • implements не даёт классу никаких прав: он лишь примеряет контракт в месте объявления, чтобы ошибка не всплыла у вызова.
  • Типы через implements не наследуются — параметр метода без аннотации остаётся неявным any и ловит TS7006.
  • Абстрактный класс совмещает требование и готовый код; new для него запрещён (TS2511), но при сборке слово стирается — и в песочнице объект всё-таки создаётся.
  • private и protected — договорённость с компилятором: в собранном коде это обычные поля, видимые в JSON.stringify и достижимые через obj["имя"].
  • Закрытое поле включает номинальную типизацию: два одинаковых по форме класса перестают быть взаимозаменяемыми (TS2322, «раздельные объявления»).
  • Настоящую приватность даёт только #поле из части I: его не видно снаружи ни компилятору, ни движку.

1/4Класс объявлен как class ConsoleLogger implements Logger, у интерфейса метод write(message: string): void, а в классе написано write(message) { ... } — без типа параметра. Что скажет компилятор?

2/4В песочнице учебника выполняется const ghost = new Shape(), где Shape — абстрактный класс с abstract area(), а затем вызывается метод, который внутри дёргает this.area(). Что увидит читатель?

3/4Два класса объявлены одинаково: у каждого единственное поле private value = 0. Присваиваем экземпляр одного в переменную типа другого. Что произойдёт?

4/4У одного объекта поле объявлено как private token = "...", у другого — как #token = "...". Оба прогоняем через JSON.stringify. Что получится?

Контракт не сошёлся

Класс подписался под интерфейс, но до контракта не дотянул. Три диагностики и TypeError — почините всё, не трогая интерфейс.

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

Подсказка: одна из трёх диагностик — не про контракт, а про то, чего implements принципиально не делает.

TS2420 на строке класса говорит о пропущенном all, TS2339 в последней строке — следствие того же, а TS7006 стоит особняком: тип параметра из интерфейса не приезжает, его нужно выписать руками.

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

Копия через slice() здесь не придирка: без неё наружу уходит сам закрытый массив, и любой желающий сможет дописать в него что угодно в обход add. Приватность полей не спасает, если метод отдаёт ссылку на внутренности.

Дыра в чертеже

Абстрактный класс задаёт общий вид отчёта, наследник обязан дать строки. Сейчас наследник пустой. Прочитайте диагностику, реализуйте rows — и добавьте второй отчёт, который печатает итого: 42.

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

Компилятор говорит прямо: TS2515: Неабстрактный класс ' UsersReport ' не реализует унаследованный абстрактный член rows из класса ' Report ' — и указывает на строку с class, а не на место вызова. Лишние пробелы вокруг имён — особенность русского перевода сообщения, не ваша опечатка.

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

Метод render написан один раз и работает для обоих наследников — ради этого абстрактный класс и берут вместо интерфейса.

Секрет, который видно

Токен объявлен закрытым, компилятор не жалуется, а в логе он всё равно есть целиком. Сделайте так, чтобы последняя строка напечатала {}, — изменив ровно один символ в объявлении поля (и его упоминания внутри класса).

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

Слово private заменяется решёткой. Оно исчезает при сборке, а # остаётся в собранном коде настоящим приватным полем — таким, какого нет ни в Object.keys, ни в сериализации.

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

Обращения внутри класса тоже переезжают: this.token становится this.#token. Решётка — часть имени поля, а не модификатор перед ним.