Функция просит таймаут в миллисекундах. Вы передаёте 5000. Позже другая функция просит таймаут в секундах. Вы передаёте 5. Где-то между ними вызываете setTimeout(duration, callback) и ничего не происходит целый час и двадцать три минуты.
TypeScript здесь не спасает. И 5000, и 5 — это number. Компилятор не видит разницы между расстоянием в метрах и расстоянием в футах, температурой по Цельсию и температурой по Фаренгейту, меткой времени и длительностью. Ваш набор тестов, скорее всего, тоже этого не поймает, потому что математика верна. Просто единицы измерения не те.
Решение — перестать считать единицы измерения документацией и начать считать их типами.
Почему number — неправильный тип для физических величин
TypeScript использует structural typing. Два объекта совместимы, если их формы совпадают. Обычно это преимущество, но для примитивов вроде number это означает, что все числа взаимозаменяемы. number есть number есть number.
Проверки во время выполнения могут ловить ошибки единиц измерения, но их дорого поддерживать и легко пропустить. Пришлось бы валидировать каждый аргумент функции, каждый ответ API, каждую константу, определённую в другом файле. На практике никто так не делает. Проверки превращаются в комментарии, а комментарии врут.
Альтернатива — закодировать единицу измерения прямо в типе. На этапе компиляции Seconds и Milliseconds становятся несовместимыми типами. Умножьте Meters на Meters — получите SquareMeters. Сложите Miles и Kilometers — компилятор откажет. Во время выполнения значение всё ещё просто число. Нет обёрточного объекта, нет проверки во время выполнения, нет потери производительности. Это zero-cost abstraction.
Как phantom types превращают число в branded unit
TypeScript не поддерживает nominal typing для примитивов, но поддерживает intersection types и unique symbols. Можно «пометить» примитив так, что две метки окажутся несовместимы, даже если базовое значение одинаково.
Вот паттерн:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
Свойство __brand не существует во время выполнения. Это phantom type. Оно существует только в системе типов. Но этого достаточно, чтобы сделать Meters и Kilometers взаимно несовместимыми.
Нельзя случайно присвоить обычный number к branded type. В этом весь смысл. Нужно явно сконструировать значение, что заставляет вас указать единицу измерения.
Рабочая система единиц измерения в TypeScript
Вот минимальная, но полная реализация, которая покрывает конструирование, преобразование и арифметику.
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
Использование:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// This does not.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
Ошибка появляется там, где возник баг, а не там, где значение в итоге используется. Не нужно отслеживать velocity через три файла, чтобы выяснить, что кто-то передал миллисекунды в параметр, ожидающий секунды.
Вывод составных единиц из базовых
Паттерн масштабируется на составные единицы. Вместо того чтобы вручную писать MetersPerSecond, можно вывести его из базовых типов с помощью generic constructor.
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
На практике полный размерный анализ может и не понадобиться. Большинство команд сталкиваются с убывающей отдачей после дюжины или около того типов единиц. Цель не в том, чтобы моделировать физику. Цель в том, чтобы устранить самый дорогой класс багов: тот, где математика верна, а единицы измерения — нет.
Компромиссы, о которых стоит знать
Branded types не бесплатны. Они стоят эргономики.
Каждый литерал нужно оборачивать в конструктор. setTimeout(callback, 5000) превращается в setTimeout(callback, milliseconds(5000)). Это больше печати. Если ваша команда непоследовательна с конструкторами, в кодовой базе окажутся разбросанные небезопасные приведения типов. Паттерн работает только если все его используют.
Вывод типов тоже шумит. Методы массивов и generic functions могут выдавать метку в сообщениях об ошибках. Простой number[] легче читать, чем (number & { readonly __brand: "Milliseconds" })[]. Возможно, понадобятся псевдонимы типов, чтобы сигнатуры оставались читаемыми.
Сериализация — ещё одна точка трения. У JSON нет понятия branded types. Когда вы отправляете значение Meters по сети, с другой стороны оно приходит как обычный number. Нужно восстанавливать метку на границе. Это правильное место для этого, но это лишний код.
Самое большое ограничение в том, что это техника только для TypeScript. Если в вашей системе есть сервисы на Python, микросервисы на Go или обычные потребители на JavaScript, метки исчезают на границе языка. Всё ещё нужна проверка во время выполнения на краях системы. Branded types защищают внутренний код на TypeScript. Они не заменяют схемы для внешних данных.
Как внедрить это, не раздражая команду
Не помечайте каждое число в кодовой базе. Начните с параметров, которые уже вызвали реальные инциденты.
- Найдите три последних бага в продакшене, связанных с единицами измерения. Ищите миллисекунды против секунд, валюты в разных номиналах, широту против долготы или экранные координаты против координат документа.
- Пометьте эти конкретные типы. Добавьте конструкторы и функции преобразования.
- Обновите функции, где используются эти значения. Пусть компилятор ведёт вас.
- Добавьте правило линтера, которое запрещает сырые
numberдля этих параметров.
Не помечайте счётчики циклов, индексы массивов или проценты. Они безразмерны. Добавление метки там — церемония без пользы.
Если вы работаете на языке с более сильным nominal typing, у вас есть варианты лучше. Пользователям Rust стоит посмотреть на крейт uom. В F# и OCaml единицы измерения встроены прямо в компилятор. Structural type system TypeScript делает это workaround’ом, а не полноценной фичей. Но этого workaround’а достаточно, чтобы ловить реальные баги.
Часто задаваемые вопросы
Добавляет ли это накладные расходы во время выполнения?
Нет. Brand — это конструкт только времени компиляции. После компиляции meters(100) — это просто число 100. Нет обёрточного объекта, нет лишнего свойства, нет проверки во время выполнения.
А как насчёт умножения и деления?
Нужны явные функции или перегруженные операторы. TypeScript не поддерживает перегрузку операторов, поэтому distance / time должно идти через функцию per() или speed(). Это многословно, но именно поэтому баг и ловится.
Можно ли использовать это со сторонними библиотеками?
Только если библиотека принимает ваш branded type. Если setTimeout ожидает number, можно передать Milliseconds, потому что brand — это пересечение с number. Обратное неверно. Если библиотека возвращает number, нужно явно пометить его, прежде чем использовать как тип единицы измерения.
Как обрабатывать дробные единицы вроде HalfSeconds?
Используйте базовую единицу и конструктор. halfSeconds(1) возвращает Milliseconds(500). Не создавайте brand для каждого подразделения. Держите количество базовых единиц небольшим.
Перестаньте писать названия единиц в именах переменных
Называть переменную timeoutInMs — это документация. Документация устаревает. Называть её timeout: Milliseconds — это тип. Типы принуждаются.
В следующий раз, когда вы будете отлаживать проблему и поймёте, что таймаут отличается в тысячу раз, спросите себя, хватило ли имени переменной. Нет. Закодируйте единицу измерения в типе, позвольте компилятору делать работу и перестаньте полагаться на свою память, пытаясь вспомнить, хочет ли эта конкретная функция секунды или миллисекунды.