Паттерны классов
Классы за пределами основ — implements против extends, почему два класса одинаковой формы совместимы, полиморфный тип this, миксины, переопределение методов при структурной типизации и fluent-билдеры, кодирующие порядок вызовов в типах.
10 вопросов
JuniorТеорияОчень частоЧем реализация интерфейса через implements отличается от extends класса?
Чем реализация интерфейса через implements отличается от extends класса?
extends наследует реализацию — члены базового класса и его цепочку прототипов, — и базовый класс ровно один. implements не наследует ничего: он лишь проверяет при компиляции, что класс объявил члены интерфейса, не порождает кода, и его можно повторить для любого числа интерфейсов.
Типичные ошибки
- ✗Ждать, что
implementsунаследует или сам сгенерирует члены интерфейса - ✗Думать, что класс может наследовать больше одного базового класса
- ✗Считать, что интерфейс доживает до рантайма и
instanceofможет его проверить
Уточняющие вопросы
- →Как переиспользовать реализацию между классами, у которых не может быть общего базового класса?
- →Меняет ли добавление
implementsпорождаемый JavaScript хоть как-нибудь?
JuniorТеорияОчень частоПочему экземпляр одного класса присваивается типу другого класса той же формы?
Почему экземпляр одного класса присваивается типу другого класса той же формы?
Потому что TypeScript сравнивает типы по структуре, а не по имени: если Point и Vec2 объявляют одинаковые публичные члены, каждый присваивается другому, хотя ни один не наследует другой. У типа класса нет своей номинальной идентичности; различает их лишь instanceof в рантайме.
Типичные ошибки
- ✗Считать, что имя класса даёт его типу номинальную идентичность, которую учитывает проверяющий
- ✗Думать, что два класса совместимы только через общий базовый класс или интерфейс
- ✗Ждать падения присваивания в рантайме из-за разных конструкторов
Уточняющие вопросы
- →Объявление какого одного члена сделает эти два класса несовместимыми?
- →Удовлетворяет ли типу класса обычный объектный литерал с теми же членами?
JuniorТеорияЧастоЧто означает метод класса, у которого объявленный тип возврата — this?
Что означает метод класса, у которого объявленный тип возврата — this?
this в позиции типа возврата — полиморфный тип this: он обозначает тип фактического получателя, поэтому унаследованный метод возвращает производный класс. Это держит цепочку: new Child().reset().childOnly() компилируется, а тип Base потерял бы childOnly.
Типичные ошибки
- ✗Читать тип возврата
thisкак рантайм-привязкуthis, а не как тип - ✗Ждать, что унаследованный метод с типом
thisвернёт базовый класс - ✗Объявлять типом возврата имя класса, из-за чего цепочка теряет производный класс
Уточняющие вопросы
- →Как ведёт себя тип возврата
thisв интерфейсе, который реализуют несколько классов? - →Чем тип возврата
thisотличается от параметраthis?
MiddleКодЧастоСохранить типизацию цепочки fluent-билдера в производном классе
Сохранить типизацию цепочки fluent-билдера в производном классе
Объявите типом возврата this, а не имя класса: where(cond: string): this с return this. Как полиморфный тип this он обозначает класс самого получателя, поэтому limit() производного класса остаётся доступен в цепочке; имя QueryBuilder потеряло бы его на первом звене.
Типичные ошибки
- ✗Аннотировать типом возврата имя класса, из-за чего производный класс теряется посреди цепочки
- ✗Переобъявлять каждый метод цепочки в производном классе, чтобы вернуть ему нужный тип возврата
- ✗Хвататься за самоссылающийся обобщённый базовый класс там, где всё выражает
this
Уточняющие вопросы
- →Что станет с цепочкой, если один из методов вернёт
new QueryBuilder()вместоthis? - →Как сделать
build()недоступным, покаwhere()не вызван хотя бы раз?
MiddleТеорияЧастоЧто именно проверяет TypeScript, когда производный класс переопределяет метод базового?
Что именно проверяет TypeScript, когда производный класс переопределяет метод базового?
Только то, что переопределение остаётся совместимым с членом базового класса: тип возврата обязан быть ковариантным, а параметры методов сравниваются бивариантно, поэтому сужение компилируется, хоть и небезопасно. Имена не связаны — опечатка добавит новый член, и это ловит noImplicitOverride.
Типичные ошибки
- ✗Считать, что сигнатура переопределения обязана точно совпадать с членом базового класса
- ✗Доверять переопределению с суженным параметром — бивариантность методов пропускает его небезопасно
- ✗Ждать, что опечатка в переопределении станет ошибкой без
noImplicitOverride
Уточняющие вопросы
- →Почему объявление члена свойством с типом функции меняет проверку вариантности?
- →Что ломается в рантайме, когда производный класс сузил параметр, а базовый зовёт его с более широким значением?
MiddleКодЧастоНавесить переиспользуемое поведение на класс без множественного наследования
Навесить переиспользуемое поведение на класс без множественного наследования
Миксин — это функция, принимающая тип конструктора и возвращающая выражение класса, наследующее его: function Stamped<T extends Ctor>(B: T) { return class extends B { at = new Date() }; }. Ограничение конструктором пробрасывает аргументы базового класса, а раз результат сам класс, вызовы миксинов вкладываются.
Типичные ошибки
- ✗Копировать методы в прототип и ждать, что тип их получит
- ✗Ограничивать базу обычным объектным типом вместо типа конструктора
- ✗Терять аргументы конструктора базового класса вместо проброса
...args
Уточняющие вопросы
- →Как потребовать, чтобы у базы уже был член, например
id: string, до применения миксина? - →Почему явный тип возврата у миксина обычно только ухудшает композицию?
SeniorТеорияИногдаКогда класс перестаёт быть взаимозаменяемым с интерфейсом такой же формы?
Когда класс перестаёт быть взаимозаменяемым с интерфейсом такой же формы?
Тип класса сравнивается структурно: ему удовлетворяет объектный литерал или несвязанный класс с теми же публичными членами. private- или protected-член это ломает — такие члены совпадают лишь по месту объявления, — поэтому два класса, каждый со своим private id, несовместимы, и никакой литерал не подойдёт.
Типичные ошибки
- ✗Считать, что тип класса принимает только экземпляры, созданные его же конструктором
- ✗Ждать, что два класса с одинаковыми
private-членами будут совместимы друг с другом - ✗Думать, что
privateстирается и потому не влияет на совместимость
Уточняющие вопросы
- →Почему производный класс от класса с
private-членом остаётся совместимым с ним? - →В чём здесь разница между
privateв TypeScript и#fieldв JavaScript?
SeniorДизайнИногдаВы проектируете HTTP-клиент с цепочкой вызовов. Правила: URL задаётся до выбора метода; body() допустим после post() или put(), но обязан быть невозможен после get(); send() допустим, только когда есть и URL, и метод; а header() можно звать в любой момент сколько угодно раз, не меняя того, что разрешено дальше. Сейчас всё это держится на рантайм-проверках внутри send(). Опишите, как типизировать цепочку, чтобы неверный порядок — client.get(url).body({}) или голый client.send() — стал ошибкой компиляции, а не исключением, и как один рантайм-класс соотносится с несколькими типами, через которые проходит цепочка.
Вы проектируете HTTP-клиент с цепочкой вызовов. Правила: URL задаётся до выбора метода; body() допустим после post() или put(), но обязан быть невозможен после get(); send() допустим, только когда есть и URL, и метод; а header() можно звать в любой момент сколько угодно раз, не меняя того, что разрешено дальше. Сейчас всё это держится на рантайм-проверках внутри send(). Опишите, как типизировать цепочку, чтобы неверный порядок — client.get(url).body({}) или голый client.send() — стал ошибкой компиляции, а не исключением, и как один рантайм-класс соотносится с несколькими типами, через которые проходит цепочка.
Каждый вызов возвращает свой тип стадии, а не тот же класс: url() даёт MethodStage только с глаголами, post() — BodyStage с body() и send(), get() — SendStage без body(). Неверный порядок непредставим: члена просто нет на типе, который вы держите, — а в рантайме все стадии реализует один класс.
Типичные ошибки
- ✗Возвращать из каждого вызова один и тот же тип, из-за чего тип не помнит, где цепочка
- ✗Пытаться сузить объединение состояний потоком управления через отдельные вызовы методов
- ✗Считать, что несколько типов-стадий требуют нескольких классов в рантайме
Уточняющие вопросы
- →Как сделать
header()доступным на любой стадии, не дублируя его в каждом интерфейсе? - →В чём компромисс между интерфейсами стадий и одним классом, обобщённым по набору сделанных вызовов?
SeniorТеорияИногдаПочему интерфейс может наследовать много интерфейсов, а класс — лишь один базовый?
Почему интерфейс может наследовать много интерфейсов, а класс — лишь один базовый?
Интерфейс лишь накапливает объявления: наследование нескольких сливает их члены, а конфликт — ошибка при объявлении, линеаризовать нечего. Класс несёт реализацию в одной цепочке прототипов, поэтому второй базовый класс сделал бы поиск члена неоднозначным. Много интерфейсов складывают контракты, но не код — код даёт миксин.
Типичные ошибки
- ✗Ждать, что
implementsнескольких интерфейсов даст хоть какую-то реализацию - ✗Считать правило одной базы произвольным, а не следствием одной цепочки прототипов
- ✗Думать, что интерфейс не может слить члены нескольких родителей
Уточняющие вопросы
- →Что произойдёт, если два наследуемых интерфейса объявят один член с несовместимыми типами?
- →Как интерфейс, наследующий класс с
private-членом, ограничивает круг тех, кто может его реализовать?
SeniorДизайнИногдаУ билдера запросов в вашем коде build() падает в рантайме всякий раз, когда не задана обязательная часть: нужны таблица и хотя бы одна выбранная колонка. Это регулярно доезжает до продакшена. Вы хотите, чтобы ошибка была ошибкой компиляции. После .from('users') билдер должен знать, что таблица задана, а build() не должен вообще существовать как вызываемый член, пока не заданы обе обязательные части, — при этом необязательные .where() и .orderBy() остаются доступны в любой момент и могут повторяться. Опишите, как вы типизируете билдер, чтобы компилятор отслеживал заданные части, каким становится тип цепочки после каждого вызова и что происходит с рантайм-объектом.
У билдера запросов в вашем коде build() падает в рантайме всякий раз, когда не задана обязательная часть: нужны таблица и хотя бы одна выбранная колонка. Это регулярно доезжает до продакшена. Вы хотите, чтобы ошибка была ошибкой компиляции. После .from('users') билдер должен знать, что таблица задана, а build() не должен вообще существовать как вызываемый член, пока не заданы обе обязательные части, — при этом необязательные .where() и .orderBy() остаются доступны в любой момент и могут повторяться. Опишите, как вы типизируете билдер, чтобы компилятор отслеживал заданные части, каким становится тип цепочки после каждого вызова и что происходит с рантайм-объектом.
Носите уже заданные части в параметре типа и возвращайте из каждого вызова суженный билдер: from() даёт Builder<S | 'table'>, select() — Builder<S | 'cols'>. Закройте build() этим накопителем через параметр this: Builder<'table' | 'cols'>, и ранний вызов не скомпилируется. Рантайм-объект не меняется, а параметр стирается.
Типичные ошибки
- ✗Считать, что порядок вызовов не выразить в системе типов и остаётся только рантайм-проверка
- ✗Возвращать из каждого метода один и тот же тип билдера, стирая уже заданное
- ✗Ждать, что анализ потока управления по полям переживёт цепочку вызовов методов
Уточняющие вопросы
- →Как сохранить ту же гарантию, если билдер неизменяемый и каждый вызов возвращает новый объект?
- →Чем этот подход платит за качество сообщения об ошибке при раннем вызове
build()?