Главная / Статьи

Энумерации и интерфейсы TypeScript как строительные блоки для низкоуровневого проектирования

Как перечисления TypeScript устраняют ошибки, связанные с «магическими» строками, когда выбирать тип или интерфейс, и как интерфейсы, методы implements и структурное типирование определяют контракты в низкоуровневом проектировании.

Издатель: REACTAPP.TOP7 мин чтения1599 слов

Источники: 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 является необязательным, но оно перемещает ошибки контрактов в определение класса, где их легче исправить.