Можете взглянуть на планы команды разработчиков TypeScript-а. Первым пунктом участники ставят введение номинативных типов в TypeScript. Судя по списку это чуть ли не следующий шаг в их работе. Однако сейчас - в марте 2022 TypeScript поддерживает только структурную типизацию. Позвольте, а как же брендирование?

Наследство JavaScript

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

Одна из особенностей JavaScript, которая перекочевала в TypeScript - возможность обращаться к значению с помощью индекс-оператора (квадратные скобки) по индексу, отсутствующему в массиве.

const getItemAt = (
  buffer: number[],
  index: number,
) => buffer[index];

В зависимости от установки флагов компилятора, в частности, флага noUncheckedIndexedAccess, TypeScript будет считать тип результата функции getItemAt либо number либо number | undefined. Согласитесь, это большая разница для дальнейшего анализа кода. А ведь одна из значимых черт TypeScript - это его способность указать на проблемное место кода. В частности, рассматривая следующий отрывок TypeScript предупредит разработчика.

const getItemAt = (
  buffer: number[],
  index: number,
) => buffer[index];

console.log(getItemAt([],0).toFixed(2));
//                         ^ возможно значение undefined 
// но только при наличии флага noUncheckedIndexedAccess

Установка флага позволяет раньше обнаружить, что Object is possibly 'undefined'. Это хорошая практика - использовать максимально возможную строгость компилятора.

О числах со смыслом

Для обсуждения возможностей TypeScript придавать смысл примитивным типам, изучим незамысловатый api для установки, чтения и фильтрации значений в буфере.

  • setItemAt - позволяет установить новое значение в локальном буфере

  • getItemAt - позволяет прочитать хранящееся там значение

  • selectMultiplesOfFive - позволяет сделать выборку из значений буфера по конкретным бизнес правилам.

Модуль может быть таким

const buffer: number[] = [];

export const getItemAt = (
  index: number,
) => buffer[index];

export const setItemAt = (
  index: number,
  value: number,
) => { buffer[index] = value; };

const rule = (probe: number) => probe % 5 === 0;

export const selectMultiplesOfFive = () => buffer.filter(rule);

Если мы воспользуемся этим api, то убедимся, что все вроде работает. Для простейших испытаний запишем и прочитаем значения по двум разным индексам.

Спойлер

Один индекс болезнетворный! - тот, который имеет значение меньше нуля. При попытке обратиться к индексу (-1) JavaScript добавляет к объекту массива "обычное" свойство, причем преобразуя число в строку. (buffer[(-1).toString()])

const illIndex = -1;
const benignIndex = 0;

//Положим два числа в буфер по разным адресам
setItemAt(illIndex, 15);
setItemAt(benignIndex, 25);

console.log(getItemAt(illIndex)); //[LOG]: 15 
console.log(getItemAt(benignIndex)); //[LOG]: 25 

console.log(selectMultiplesOfFive());
//[LOG]: [25] <-- в выборке только одно значение!!!

Важно отметить, что только пристальное рассмотрение результатов работы программы может подсказать нам, что что-то пошло не так. Во время испытания мы не получим никаких предупреждений ни от компилятора, ни от движка JavaScript. А если буфер содержит множество значений, то определить верно ли отработал фильтр будет очень сложно. Такое незаметное нехорошее поведение может оставаться в программе долгое время, пока не приведет к каким либо совсем уж катастрофическим последствиям. Это большой риск.

Внимательный читатель может предложить усовершенствовать реализацию функции setItemAt, добавить в нее проверку, как-нибудь так:

//v2.0
export const setItemAt = (
  index: number,
  value: number,
) => {
  if (index < 0) {
    throw new RangeError();
  }
  buffer[index] = value;
};

Такая реализация решает проблему записи по болезнетворному индексу во время работы программы, но не позволяет обнаружить такую попытку во время компиляции, в редакторе. Ничто, кроме документации к нашему api, которая устарела и, поэтому, ее никто не читает, не подскажет разработчику предпринять меры предосторожности при операциях ввода значений. Но, мы ведь заинтересованы передать заказчику код, который работает устойчиво?

Брендирование в помощь

Как все-таки донести до пользователя api и до компилятора TypeScript, что программа не будет, не должна, не может работать с отрицательными индексами. Индекс должен быть целым положительным числом. Хотя в JavaScript нет беззнакового типа целых чисел, мы можем его предложить.

//v3.0
type Uint = number & {"unsigned int": void}; //определили тип

const buffer: number[] = [];

export const getItemAt = (
  index: Uint, //поменяли тип аргумента
) => buffer[index];

export const setItemAt = (
  index: Uint, //поменяли тип аргумента
  value: number,
) => {
  buffer[index] = value;
};

const rule = (probe:number) => probe % 5 === 0;

export const selectMultiplesOfFive = () => buffer.filter(rule);

Наиболее интересная часть версии 3.0 в первой строке предыдущего отрывка.

Мы обозначили именем Uint пересечение типов number и {"unsigned int": void}.

По началу такое скрещивание ежа с ужом выглядит странно. Разве среди чисел найдется такое, которое имеет свойство "unsigned int"? Но если вдуматься, то это свойство не добавляет значения, оно void. Зато со значениями типа Uint возможны все операции, допустимые по отношению к типу number. В частности обратите внимание на строки 8 и 14 предыдущего отрывка.

Если вы еще не знакомы с брендированием в TypeScript, то, скорее всего, вас интересует вопрос: Как создавать значения таких экзотических типов? в Java, в C#, в Delphi значения кастомных типов (классов) создаются конструкторами. В TypeScript тоже нужен конструктор, вернее функция-фабрика.

const makeUint = (value: number)=>{
  if(!Number.isInteger(value) || value<0){
    throw new TypeError();
  }
  return value as Uint;
}

Клиентский код

Теперь версия 3.0 нашего api может быть задействована в испытаниях.

const illIndex = makeUint(-1);//run-time ошибка случится ближе к источнику ввода
const benignIndex = makeUint(0);

setItemAt(illIndex, 15);
setItemAt(benignIndex, 25);

console.log(getItemAt(illIndex));
console.log(getItemAt(benignIndex));

console.log(selectMultiplesOfFive());

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

Разработчик не сможет передать в setItemAt некорректный индекс, и ему придется подумать о возможных вариантах поведения программы заранее. Разве это не успех?

Считаете ли вы, что имитация номинативных типов через брендирование улучшает устойчивость программы?

Комментарии (4)


  1. Nipheris
    10.03.2022 18:57
    +11

    Считаете ли вы, что имитация номинативных типов через брендирование улучшает устойчивость программы?

    Используем вот такую штуку года где-то с 2018-го:

    export type Nominal<T, Name extends string> = T & { [Symbol.species]: Name };

    применяется например вот так:

    type Price = Nominal<number, 'Price'>;

    Качество кода выросло очень заметно.


    1. iliazeus
      11.03.2022 10:28

      Я думаю, что brand лучше делать ключом, а не значением, чтобы были возможны штуки вроде Price & NonNegative. Что-то вроде:

      const PriceBrand: unique symbol = Symbol();
      type Price = number & { [typeof PriceBrand]: true };


      1. nin-jin
        11.03.2022 10:57

        Не стоит делать анонимные символы - отлаживать потом замучаетесь.


  1. iliazeus
    11.03.2022 10:22

    Беда с брендированием конкретно примитивных типов вроде number, в первую очередь, в том, что стандартные арифметические операторы ничего об этом не знают. Поэтому нужны либо makeUint на каждом шагу, либо свои функции-обертки вроде add(Uint, Uint). Я все надеюсь, что добавят перегрузки операторов на уровне объявлений, как сейчас есть для функций.