TypeScript / 38 минут

Классы и структурная совместимость

Сравните контракты экземпляров, implements, модификаторы доступа и runtime-поля.

В процессе Теория: впереди Практика: впереди Тест: впереди
11 / 12
После этой главы
  • Сравните контракты экземпляров, implements, модификаторы доступа и runtime-поля.
  • Применить тему в задаче «Классы и структурная совместимость» и объяснить граничные случаи.

Около 8 минут на чтение и разбор примеров. Практика — отдельно, в своём темпе.

Две стороны объявления класса

Класс в TypeScript создаёт runtime-значение конструктора и одновременно даёт тип экземпляра. Если переменная объявлена как Counter, она обычно должна хранить экземпляр с соответствующей структурой, а не сам конструктор класса. Для типа конструктора используют отдельное описание или typeof Counter. Это различие особенно важно при передаче классов в фабрики и DI-контейнеры.

Не забывайте, что TypeScript сохраняет основную модель классов JavaScript: new создаёт объект, constructor инициализирует поля, методы доступны через прототип. Аннотации помогают проверить использование, но не отменяют runtime-поведение this, наследования и отдельных экземпляров. Статическая успешность не превращает общую глобальную переменную в независимое состояние каждого объекта.

implements и ответственность реализации

implements проверяет, что экземпляр удовлетворяет интерфейсу. Он не генерирует тела методов и не подставляет автоматически тип каждого параметра реализации во всех ситуациях. Интерфейс может описывать минимальный контракт зависимости, благодаря чему сервису всё равно, какой конкретный класс его исполняет.

TypeScript
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-семантику приватности.

TypeScript
class SessionView {
  #token = 'runtime-private';
  private label = 'static-private';
  describe(): string { return this.label; }
}

Даже runtime-приватное поле в клиентском приложении не является надёжным местом хранения серверного секрета, который вообще не должен попасть пользователю. Инкапсуляция внутри программы и граница доверия между сервером и клиентом решают разные задачи. Не используйте модификатор доступа как замену серверной авторизации.

Структурная совместимость и особые члены

Два объекта могут соответствовать одному интерфейсу без общего родителя. Это облегчает тестовые реализации и композицию. Но private и protected добавляют особенности совместимости, связанные с происхождением соответствующих членов. Поэтому фраза «имена классов никогда не имеют значения» слишком груба для всех случаев TypeScript.

TypeScript
class FixedCounter {
  value = 5;
  add(_amount: number): void {}
}
const counter: Counter = new FixedCounter();

Объект соответствует форме Counter, хотя его поведение может быть неподходящим предметной области. TypeScript проверяет типы членов, а не доказывает, что add действительно прибавляет сумму. Для семантики нужны тесты контракта. Это хороший пример того, почему статическая проверка и автоматические поведенческие проверки дополняют друг друга.

Абстрактные классы и композиция

Абстрактный класс может задать контракт и часть общей реализации, но его нельзя напрямую создать как обычный конкретный экземпляр. Он полезен, когда общий runtime-класс действительно нужен. Для простой границы зависимости нередко достаточно interface и явного токена DI. В NestJS интерфейс исчезает после компиляции, поэтому сам по себе не является токеном контейнера.

TypeScript
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 недостаточно, если разные реализации по-разному трактуют смысл операции.

Загружаем редактор…
Следующая темаМодули, tsconfig и сборка

Музыка: Reverie — Scott Buckley · CC BY 4.0