Номинативная типизация в TypeScript или как защитить свой интерфейс от чужих идентификаторов


Недавно, изучая причины некорректной работы своего домашнего проекта, я в очередной раз заметил за собой ошибку, которая часто повторяется из-за усталости. Суть ошибки сводится к тому, что, имея в одном блоке кода несколько идентификаторов, при вызове некоторой функции я передаю идентификатор объекта другого типа. В данной статья я расскажу о том, как решить эту проблему средствами TypeScript.


Немного теории

TypeScript основан на структурной типизации, что хорошо ложится на утиную идеологию JavaScript. Об этом написано достаточной статей. Я не буду их повторять, лишь обозначу основное отличие от номинативной типизации, которая более распространена в других языках. Разберем небольшой пример.

class Car {
id: number;
numberOfWheels: number;
move (x: number, y: number) {
// некая реализация
}
}

class Boat {
id: number;
move (x: number, y: number) {
// некая реализация
}
}

let car: Car = new Boat(); // здесь TypeScript выдаст ошибку
let boat: Boat = new Car(); // а на этой строчке все в порядке

Почему TypeScript поведет себя именно так? Это как раз является проявлением структурной типизации. В отличие от номинативной, которая следит за названиями типов, структурная типизация принимает решение о совместимости типов на основе их содержимого. Класс Car содержит все свойства и методы класса Boat, поэтому Car может использоваться в качестве Boat. Обратное неверно, потому как в Boat отсутствует свойство numberOfWheels.

Типизируем идентификаторы

Первым делом зададим типы для идентификаторов

type CarId: number;
type BoatId: number;

и перепишем классы и использованием данных типов.

class Car {
id: CarId;
numberOfWheels: number;
move (x: number, y: number) {
// некая реализация
}
}

class Boat {
id: BoatId;
move (x: number, y: number) {
// некая реализация
}
}

Вы заметите, что ситуация не сильно изменилась, ведь мы по прежнему не имеем контроля над тем, откуда мы взяли идентификатор, и будете правы. Но этот пример уже дает некоторые преимущества.

  • В процессе разработки программы может внезапно поменяться тип идентификатора. Так, например, некоторый числовой номер автомобиля, уникальный для проекта, может быть заменен на строковой VIN номер. Без задания типа идентификатора придется во всех местах, где он встречается, заменить number на string. С заданием типа, изменение нужно будет сделать только в одном месте, где определяется сам тип.

  • При вызове функций мы получаем подсказки от нашего редактора кода, какого типа должны быть идентификаторы. Допустим у нас объявлены следующие функции:

    function getCarById(id: CarId): Car {
    // …
    }
    function getBoatById(id: BoatId): Boat {
    // …
    }

    Тогда мы получим от редактора подсказку, что должны передать не просто число, а CarId или BoatId.

  • Эмулируем самую строгую типизацию

    Номинальной типизации в TypeScript нет, но мы можем сэмулировать его поведение, сделав любой тип уникальным. Для этого в тип необходимо добавить уникальное свойство. Этот прием упоминается в англоязычных статьях под термином Branding, и вот как это выглядит:

    type BoatId = number & { _type: ‘BookId’};
    type CarId = number & { _type: ‘CarId’};

    Указав, что наши типы должны одновременно быть и числом, и объектом со свойством с уникальным значением, мы сделали наши типы несовместимыми в понимании структурной типизации. Посмотрим, как это работает.

    let carId: CarId;
    let boatId: BoatId;

    let car: Car;
    let boat: Boat;

    car = getCarById(carId); // OK
    car = getCarById(boatId); // ERROR

    boat = getBoatById(boatId); // OK
    boat = getBoatById(carId); // ERROR

    carId = 1; // ERROR
    boatId = 2; // ERROR
    car = getCarById(3); // ERROR
    boat = getBoatById(4); // ERROR

    Все выглядит неплохо за исключением четырех последних строчек. Для создания идентификаторов понадобится функция-хелпер:

    function makeCarIdFromVin(id: number): CarId {
    return vin as any;
    }

    Недостатком этого способа является то, что данная функция останется в рантайме.

    Делаем строгую типизацию чуть менее строгой

    В прошлом примере для создания идентификатора приходилось использовать дополнительную функцию. Избавиться от нее можно с помощью определения интерфейса Flavor:

    interface Flavoring<FlavorT> {
    _type?: FlavorT;
    }
    export type Flavor<T, FlavorT> = T & Flavoring<FlavorT>;

    Теперь задать типы для идентификаторов можно следующим образом:

    type CarId = Flavor<number, “CarId”>
    type BoatId = Flavor<number, “BoatId”>

    Поскольку свойство _type является необязательным, можно воспользоваться неявным преобразованием:

    let boatId: BoatId = 5; // OK
    let carId: CarId = 3; // OK

    И мы по прежнему не можем перепутать идентификаторы:

    let carId: CarId = boatId; // ERROR
    Какой вариант выбрать

    Оба варианта имеют право на существование. Branding имеет преимущество, когда необходимо защитить переменную от прямого присваивания. Это полезно, если переменная хранит строку в некотором формате, например абсолютный путь файла, дата или IP адрес. Функция-хелпер, которая занимается приведением типов в этом случае так же может выполнять проверку и обработку входных данных. В остальных случаях удобнее использовать Flavor.

    Источники

  • Отправная точка stackoverflow.com
  • Вольная интерпретация статьи
  • Оставить комментарий

    Рубрики сайта
    • Рубрик нет