Энумерации и интерфейсы TypeScript как строительные блоки для низкоуровневого проектирования
Как перечисления TypeScript устраняют ошибки, связанные с «магическими» строками, когда выбирать тип или интерфейс, и как интерфейсы, методы implements и структурное типирование определяют контракты в низкоуровневом проектировании.
Источники: Day 1: (Enums and Interfaces) LLD With TS | Part 2
После классов и объектов следующими инструментами низкоуровневого проектирования (LLD) в TypeScript являются перечисления и интерфейсы. Они кажутся простыми, но обеспечивают единообразие дизайна: перечисления фиксируют допустимые значения, а интерфейсы — контракты объектов. В этой статье рассматриваются ошибки, которые они предотвращают, как выбирать между type и interface, а также что означает структурное типирование для implements.
Проблема, которую решают перечисления: разрозненные «магические» строки
Предположим, что заказ в вашем приложении может находиться в одном из трех состояний:
PENDING
SUCCESS
REJECTED
Код в одном модуле проверяет наличие заказа в ожидании следующим образом:
if (order.status === "PENDING") {
// ...
}
Другой модуль проверяет статус успешности:
if (order.status === "SUCCESS") {
// ...
}
Всё работает до тех пор, пока новый коллега не напишет проверку, похожую по форме, но использующую строчные буквы:
if (order.status === "pending") {
// ...
}
Сравнение строк чувствительно к регистру, поэтому это разные значения:
"PENDING" !== "pending"
Условие никогда не становится истинным. Коренная причина — вручную введенные значения повсюду: кто помнит, было ли это PENDING, pending или Pending?
Определение допустимых значений один раз
Энумерация объявляет фиксированный набор именованных значений в одном месте, чтобы остальной код мог обращаться к ним по имени. Здесь каждому элементу явно присваивается его строковое значение:
enum OrderStatus {
PENDING = "PENDING",
SUCCESS = "SUCCESS",
REJECTED = "REJECTED"
}
Вместо сравнения с необработанной строкой, как в этой проверке,
if (order.status === "PENDING") {
// ...
}
сравнивайте с элементом энумерации:
if (order.status === OrderStatus.PENDING) {
// ...
}
То же самое касается всех остальных проверок статуса:
if (order.status === OrderStatus.SUCCESS) {
// ...
}
Никому не нужно запоминать точное написание, достаточно имени элемента, которое автодополняется редактором:
OrderStatus.PENDING
Опечатка в имени члена теперь приводит к ошибке компиляции, а не к скрытому несоответствию во время выполнения.
Почему это важно по мере роста кодовой базы
Представьте, что этот статус отображается в двадцати местах. Без enum соблюдение единообразия зависит от дисциплины, и появляются различия:
"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"
С помощью enum каждое место ссылается на одно и то же объявление:
OrderStatus.PENDING
Короче говоря, enum — это именованный, заранее определенный набор констант.
Enum строк легче отлаживать, чем числовые, поскольку их значения остаются читаемыми в логах. Союз строковых литералов, показанный ниже, обеспечивает аналогичную безопасность без кода во время выполнения, что некоторые команды предпочитают.
тип против интерфейса
Для собеседований и тестирования достаточно простой умственной модели этих понятий.
тип: комбинирование и моделирование типов
- Гибкость при объединении типов.
Типичным примером является объединение литеральных строк:
type Status = "PENDING" | "SUCCESS" | "FAILED";
Так же как и идентификатор, который может быть либо строкой, либо числом:
type ID = string | number;
Интерфейс: контракты для объектов и классов
- В основном описывает структуру объектов и требования, которым должны соответствовать классы.
- Может быть реализован классом с использованием ключевого слова
implements. - Идеально подходит, когда несколько реализаций должны соблюдать один и тот же контракт.
- Поддерживает объединение деклараций, так что две декларации с одинаковым именем объединяются в одну.
interface Payment {
amount: number;
pay(): void;
}
Руководящее правило
Когда вам нужно составить или смоделировать тип, используйте type:
Need to compose/model a type?
↓
type
Когда вам нужен контракт между классами или объектами, обратитесь к interface:
Need a contract between classes/objects?
↓
interface
Это рекомендация, а не правило: оба подхода могут описывать структуру объектов.
Интерфейсы описывают структуру объектов
Самый простой способ познакомиться с интерфейсами — это подумать о структуре объекта. Вот простой объект на JavaScript:
const student = {
id: 101,
name: "Rahul",
age: 20
};
TypeScript позволяет описать такую структуру, например с помощью псевдонима типа:
type Student = {
id: number;
name: string;
age: number;
};
Затем несколько объектов можно проверить на соответствие одной и той же структуре:
const student1: Student = {
id: 101,
name: "Rahul",
age: 20
};
const student2: Student = {
id: 102,
name: "Aman",
age: 21
};
По мере роста приложения именование такой структуры как повторно используемого контракта — вот для чего существуют интерфейсы.
Интерфейс как контракт
Та же структура, выраженная в виде интерфейса:
interface Student {
id: number;
name: string;
age: number;
}
Любое значение, которое должно рассматриваться как Student, теперь должно соответствовать этому интерфейсу:
const student: Student = {
id: 101,
name: "Rahul",
age: 20
};
Пропустить обязательное свойство, как здесь,
const student: Student = {
id: 101,
name: "Rahul"
};
и компилятор выдаст ошибку из-за отсутствия этого свойства:
age
По сути, интерфейс гласит: чтобы объект считался этим типом, он должен соответствовать данному контракту.
Интерфейсы определяют что, а не как
Это различие имеет ключевое значение в LLD. Интерфейс указывает, каким должен быть объект или какие функции он должен выполнять, но не описывает способ их реализации. Возьмем контракт оплаты:
interface Payment {
amount: number;
pay(): void;
}
Он гарантирует ровно две вещи:
Payment must have:
→ amount
→ pay()
В нем не говорится ничего о том, как работает метод pay(). Один класс может обрабатывать оплату картой, используя обязательное свойство amount,
class CreditCardPayment implements Payment {
amount: number;
и предоставлять собственную реализацию метода pay():
pay() {
console.log("Paying using credit card");
}
}
Другой класс может осуществлять оплату через UPI — систему мгновенных платежей в Индии, используя то же самое свойство.
class UPIPayment implements Payment {
amount: number;
и другой метод pay():
pay() {
console.log("Paying using UPI");
}
}
Тот же контракт, но разные реализации, поэтому вызывающие коды могут работать с любой формой оплаты:
Interface
↓
Defines the contract
↓
Multiple implementations
↓
Different implementation details
Ключевое слово implements
implements явно указывает на связь между классом и контрактом.
interface Payment {
amount: number;
pay(): void;
}
Класс заявляет, что соответствует этому контракту:
class CreditCardPayment implements Payment {
amount: number; pay() {
console.log("Paying with credit card");
}
}
Это условие
implements Payment
запрашивает у компилятора проверку на соответствие класса контракту Payment. Если это не так, например из-за отсутствия метода pay(),
class CreditCardPayment implements Payment {
amount: number;
}
компилятор выдает предупреждение об отсутствующем элементе
pay()
Метод implements представляет собой исключительно проверку на этапе компиляции; он ничего не добавляет во время выполнения. Один момент при тестировании этих примеров: при включенном режиме strictPropertyInitialization объявленное поле вроде amount: number; без инициализатора или присваивания в конструкторе само по себе является ошибкой, поэтому необходимо задать ему значение.
Структурное типирование: форма важнее объявлений
В отличие от Java или C#, TypeScript использует структурное типирование. Классу не обязательно делать объявления
implements Payment
чтобы его приняли там, где ожидается объект типа Payment. Рассмотрим контракт с одним методом:
interface Payment {
pay(): void;
}
и класс, в котором он нигде не упоминается:
class UPIPayment {
pay() {
console.log("Paying through UPI");
}
}
Даже без написания
implements Payment
функции, ожидающей объект типа Payment
function processPayment(payment: Payment) {
payment.pay();
}
он с радостью принимает экземпляр этого класса:
const payment = new UPIPayment();processPayment(payment);
Это компилируется, потому что у UPIPayment есть необходимая структура:
{
pay(): void;
}
Для TypeScript достаточно соответствия структуре; явное объявление является необязательным.
Тогда зачем вообще писать implements?
Если структурное типирование уже позволяет принимать классы, соответствующие определению, implements может показаться избыточным. Его ценность заключается в том, что он проверяет контракт, где определён класс, а не только место его использования. Учитывая этот контракт
interface Payment {
amount: number;
pay(): void;
}
и класс, который явно его реализует,
class UPIPayment implements Payment {
amount = 100; pay() {
console.log("Paying through UPI");
}
}
предположим, что позже к интерфейсу добавят параметр валюты:
interface Payment {
amount: number;
currency: string;
pay(): void;
}
Компилятор сразу сообщит, что UPIPayment больше не соответствует контракту, указывая на сам класс. Без этого ошибка появилась бы только в каком-то отдалённом месте вызова или вообще не появилась бы.
Основные выводы
- Enum позволяют заменить разрозненные магические строки одним объявлением, превращая опечатки в ошибки компиляции.
- Объединения строковых литералов — это легковесная альтернатива, когда не требуется объект во время выполнения.
- Используйте
typeдля комбинирования типов (объединений, пересечений, отображенных типов), аinterface— для контрактов, которые реализуют классы. - Интерфейсы описывают, что предоставляет объект, но не как это делается, что позволяет использовать взаимозаменяемые реализации.
- TypeScript проверяет структуру типов, поэтому использование ключевого слова
implementsявляется необязательным, но оно перемещает ошибки контрактов в определение класса, где их легче исправить.