TypeScript / 38 минут
Классы и структурная совместимость
Сравните контракты экземпляров, implements, модификаторы доступа и runtime-поля.
- Сравните контракты экземпляров, implements, модификаторы доступа и runtime-поля.
- Применить тему в задаче «Классы и структурная совместимость» и объяснить граничные случаи.
Около 8 минут на чтение и разбор примеров. Практика — отдельно, в своём темпе.
Две стороны объявления класса
Класс в TypeScript создаёт runtime-значение конструктора и одновременно даёт тип экземпляра. Если переменная объявлена как Counter, она обычно должна хранить экземпляр с соответствующей структурой, а не сам конструктор класса. Для типа конструктора используют отдельное описание или typeof Counter. Это различие особенно важно при передаче классов в фабрики и DI-контейнеры.
Не забывайте, что TypeScript сохраняет основную модель классов JavaScript: new создаёт объект, constructor инициализирует поля, методы доступны через прототип. Аннотации помогают проверить использование, но не отменяют runtime-поведение this, наследования и отдельных экземпляров. Статическая успешность не превращает общую глобальную переменную в независимое состояние каждого объекта.
implements и ответственность реализации
implements проверяет, что экземпляр удовлетворяет интерфейсу. Он не генерирует тела методов и не подставляет автоматически тип каждого параметра реализации во всех ситуациях. Интерфейс может описывать минимальный контракт зависимости, благодаря чему сервису всё равно, какой конкретный класс его исполняет.
interface Counter {
readonly value: number;
add(amount: number): void;
}
class MemoryCounter implements Counter {
constructor(public value: number) {}
add(amount: number): void { this.value += amount; }
}Здесь внешнее представление Counter обещает чтение value, а сам класс может управлять его изменением. Parameter property public value одновременно объявляет и инициализирует поле. Это сокращение TypeScript, а не отдельный стандартный синтаксис JavaScript без трансформации. Выбирайте такие сокращения осознанно, чтобы читателю было видно состояние объекта.
public, protected, private и #field
public обозначает доступный член, protected ограничивает обычный статический доступ классом и наследниками, private — самим классом в соответствующей модели TypeScript. Эти ограничения в основном проверяются компилятором и не равны физическому сокрытию произвольного поля JavaScript. #field имеет собственную runtime-семантику приватности.
class SessionView {
#token = 'runtime-private';
private label = 'static-private';
describe(): string { return this.label; }
}Даже runtime-приватное поле в клиентском приложении не является надёжным местом хранения серверного секрета, который вообще не должен попасть пользователю. Инкапсуляция внутри программы и граница доверия между сервером и клиентом решают разные задачи. Не используйте модификатор доступа как замену серверной авторизации.
Структурная совместимость и особые члены
Два объекта могут соответствовать одному интерфейсу без общего родителя. Это облегчает тестовые реализации и композицию. Но private и protected добавляют особенности совместимости, связанные с происхождением соответствующих членов. Поэтому фраза «имена классов никогда не имеют значения» слишком груба для всех случаев TypeScript.
class FixedCounter {
value = 5;
add(_amount: number): void {}
}
const counter: Counter = new FixedCounter();Объект соответствует форме Counter, хотя его поведение может быть неподходящим предметной области. TypeScript проверяет типы членов, а не доказывает, что add действительно прибавляет сумму. Для семантики нужны тесты контракта. Это хороший пример того, почему статическая проверка и автоматические поведенческие проверки дополняют друг друга.
Абстрактные классы и композиция
Абстрактный класс может задать контракт и часть общей реализации, но его нельзя напрямую создать как обычный конкретный экземпляр. Он полезен, когда общий runtime-класс действительно нужен. Для простой границы зависимости нередко достаточно interface и явного токена DI. В NestJS интерфейс исчезает после компиляции, поэтому сам по себе не является токеном контейнера.
abstract class Repository {
abstract find(id: string): Promise<string | null>;
}
class MemoryRepository extends Repository {
async find(_id: string): Promise<string | null> { return null; }
}Композиция передаёт зависимость объекту через конструктор или фабрику вместо глубокой иерархии наследования. Это облегчает подмену хранилища и разделение жизненных циклов. Не наследуйте сервис от конкретного HTTP-клиента только ради доступа к паре методов: часто лучше хранить клиент как отдельную зависимость с узким контрактом.
Практика реализации интерфейса
В задании MemoryCounter реализует Counter, получает начальное значение и применяет все steps. Каждый solve создаёт новый экземпляр. Отрицательные шаги уменьшают значение, нулевые сохраняют его. Тип void у add не означает запрет побочного эффекта: как раз этот метод изменяет состояние экземпляра по явному правилу.
Проверьте пустой список шагов и два последовательных независимых вызова solve. Затем замените поле value приватным #value и предоставьте getter, сохранив интерфейс чтения. Для самостоятельного продолжения создайте вторую реализацию Counter и проверьте один и тот же поведенческий контракт для обеих. Одного implements недостаточно, если разные реализации по-разному трактуют смысл операции.