Номинативная типизация в 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) {
        // некая реализация
    }
}

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


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


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


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

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



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


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


type BoatId = number & { _type: 'BoatId'};
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.


Источники


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

Комментарии 13

    0
    >В данной статья я расскажу о том, как решить эту проблему средствами TSLint
    Причем тут tslint?
      +1

      Если TSLint подключен к редактору, он в на этапе написания программы подсветит неверную подстановку переменной сообщением об ошибке вида


      Type 'BookId' is not assignable to type 'AuthorId'.
        +3
        Это сделает typescript, а не tslint
          +2
          Спасибо за замечание, сейчас исправлю статью.
      +1

      Вот только при таком подходе у идентификаторов появляется какое-то левое поле _type. Лучше использовать символ:


      const flavorType = Symbol("flavorType");
      
      export type Flavor<T, FlavorT> = T & { [flavorType]?: FlavorT };

      По-хорошему, ещё и строки "CarId" с "BoatId" нужно за символами спрятать, но TS пока что так не умеет.

        +1

        Да, вместо _type можно использовать как _myProjectNameType, так и Symbol, если есть опасения из-за возможного конфликта имен. Главное, что после компиляции из TypeScript в JavaScript эта информация о типах будет удалена.
        Кстати, сами разрабочики TypeScript в своих исходниках используют брендинг. Поэтому использование левого поля _type можно считать допустимым.

        0
        Интересное решение, но непонятно, насколько оно стабильное. Может оказаться, что в будущих версиях Typescript станет умнее, и будет игнорировать поле `_type` потому что оно опциональное и нигде не используется. Тогда CarId и BoatId внезапно станут одинаковым типом.

        Хотелось бы узнать, кто что думает об этом аспекте.
          0
          Typescript не может знать что свойство нигде не используется потому что всегда может существовать какой-то неизвестный TS модуль к этому самому свойству обращающийся.
          +1
          >> Эмулируем самую строгую типизацию
          Здесь опечатка: type BoatId = number & { _type: 'BookId'};

          Все мы поняли что там должно быть BoatId. Но это тот самый случай, когда могут 2 разных типа проскочить по одному имени :(

          Кстати, как-то копался на github и newtype-ts
            0
            Спасибо за уточнение. Сейчас исправлю статью. Изначально хотел сделать примеры с книгами и авторами — при переименовании проскочило старое название :)
            0

            Можно ещё так:


            declare const CarSymbol: unique symbol;
            type CarId = string & typeof CarSymbol;
            
            declare const BoatSymbol: unique symbol;
            type BoatId = string & typeof BoatSymbol;
            
            function processCar(id: CarId) {}
            function processBoat(id: BoatId) {}
            function processString(value: string) {}
            
            let carId = <CarId>'car uuid';
            let boatId = <BoatId>'boat uuid';
            
            processCar(carId);
            processCar(boatId); // error
            
            processBoat(carId); // error
            processBoat(boatId);
            
            processString(carId); // ok
            processString(boatId); // ok

            Вообще горячий топик
            https://github.com/Microsoft/TypeScript/issues/202

              0

              Выглядит как багоюз. Тип string & symbol должен быть эквивалентен never, и однажды в очередной версии компилятора он им и станет...

              –2

              Самая удобная реализация, что я смог придумать, есть в $mol_data_nominal.


              const Weight = $mol_data_nominal({ Weight : $mol_data_integer })
              const Length = $mol_data_nominal({ Length : $mol_data_integer })
              
              let len = Length(10)
              len = Length(20) // Validate
              len = 20 as typeof Length.Value // Cast
              
              len = 20 // Compile time error
              len = Weight(20) // Compile time error
              len = Length( 20.1 ) // Run time error

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое