10 фев. 2022
РедактироватьTypeScript - номинальная типизация примитивных типов
Координаты
В моих задачах мне приходится много работать с библиотеками, использующими координаты. Разные библиотеки могут сохранять/передавать значения координат в разном виде. Например, это может быть объект с двумя полями или массив с двумя значениями. С массивом возникают некоторые сложности. В какой последовательности — и почему именно в такой — должны идти долгота и широта? Каждый автор библиотеки решает эту проблему по-своему, а нам страдать.
Было бы классно, написать что-то такое:
type Lat = number;
type Lng = number;
type LatLng = [Lat, Lng];
type LngLat = [Lng, Lat];
Но это не сработает, потому что в typescript используется структурная типизация,
и оба типа работают как [number, number]
. К счастью, номинальную типизацию
можно эмулировать. Для этого к number
нужно добавить уникальный признак:
type Lat = number & { __type: 'Lat' };
type Lng = number & { __type: 'Lng' };
Теперь типы Lat
и Lng
не будут равны друг другу, что нам и нужно было.
Такой подход называется brand/opaque type. И чтобы было удобнее,
можно написать алиас.
type Brand<T, U> = T & { __type: U };
Или взять готовую реализацию из библиотеки с утилитарными типами, например из utility-types или type-fest.
Тогда использование будет выглядеть как-то так:
type Lat = Brand<number, 'Lat'>;
type Lng = Brand<number, 'Lng'>;
type LatLng = [Lat, Lng];
const lat = 59.57 as Lat;
const lng = 30.19 as Lng;
const fn = (coords: LatLng) => {...};
fn([lat, lng]) // Всё ок
fn([lng, lat]) // Ошибка
Кажется, что с адресом электронной почты можно поступить так же:
type Email = Brand<string, 'Email'>;
Но на самом в typescript 4.1 появились литеральные типы, и они позволяют творить магию. Например можно написать так:
type Email = `${string}@${string}.${string}`;
Таким образом, можно проверить что тут не случайная строка, а строка в особом формате, немного похожем на email.
const notEmail: Email = 'example'; // Ошибка
const email: Email = '[email protected]'; // Всё ок
Но тут нужно быть осторожным, ибо одно неверное движение — и всё сломается.
// Ошибки нет, хотя строка не правильного формата
const notEmail = 'example' as Email;
Валюты и немного дженериков
Представим, что приложение работает с разными валютами. И хочется на уровне типов запретить складывать доллары с рублями без конвертации.
type Rub = Brand<number, 'Rub'>;
type Usd = Brand<number, 'Usd'>;
type Eur = Brand<number, 'Eur'>;
Здесь полностью запретить сложение разных валют не выйдет, но если всегда для сложения валют использовать функцию — всё будет работать.
const sumRub = (a: Rub, b: Rub): Rub => {...};
В таком случае, нам для каждой валюты придётся писать свои функции.
Но можно использовать дженерики:
const sum = <T extends>(a: T, b: T): T => ...;
const aRub = 300 as Rub;
const bRub = 600 as Rub;
const cUsd = 30 as Usd;
sum(aRub, bRub) // Всё ок
sum(aRub, cUsd) // Ошибка
А теперь используя brand type и литеральные типы, напишем функцию для конвертации любой валюты в usd.
const toUsd = <Value extends Brand<number, B>, B extends string>(value: Value, currency: Brand<number, `${Value['__type']}ToUsd`>): Usd => {};
type RubToUsdCurrency = Brand<number, 'RubToUsd'>;
const rubToUsdCurrency = 30 as RubToUsdCurrency;
toUsd(rub, rubToUsd); // Всё ок
toUsd(eur, rubToUsd); // Ошибка
Немного маги. Первый аргумент в дженерике - любой brand type.
Второй аргумент - уникальная строка из brand type.
Такое усложнение нужно, чтобы можно было вытащить эту уникальную строку.
Тип currency можно было бы задать как Brand<number, `${B}ToUsd`>
, но тогда
при неправильном вызове функции typescript будет ругаться на первый аргумент.
Если же написать Brand<number, `${Value['__type']}ToUsd`>
, то ошибка будет
во втором аргументе, что логичнее.