TypeScript Patterns. Номинальные типы

В этом посте я хочу начать цикл практических статей про advanced-level практики в TypeScript (далее TS).
Далеко не все осознают насколько мощный инструмент достался frontend-сообществу и какие он открывает возможности для того, чтобы писать удобный, выразительный и супер-надежный код. Особенно это актуально в больших проектах. По моим ощущениям, большинство разработчиков используют TS процентов на 30% от его потенциала. Надеюсь данный цикл статей поможет немного исправить ситуацию.
Вводный пример
Мы хотим использовать в своем приложении API с данными о погоде за определенные даты. URL Get-запроса выглядит так - https://archive-api.open-meteo.com/v1/era5?latitude=52.52&longitude=13.41&start_date=2021-01-01&end_date=2021-01-03&hourly=temperature_2m. Как мы можем описать в TS интерфейс функции, которая будет выполнять этот запрос? Обычно это делают следующим образом:
// Объявляем функцию и тип параметров
type GetWeatherRequestParams = {
latitude: number
longitude: number
start_date: string
end_date: string
hourly: 'temperature_2m' | 'windspeed_10m'
}
export function getWeather({ longitude, latitude, start_date, end_date, hourly }: GetWeatherRequestParams) {
const baseUrl = 'https://archive-api.open-meteo.com/v1/era5'
return fetch(`${baseUrl}?latitude=${latitude}&longitude=${longitude}&start_date=${start_date}&end_date=${end_date}&hourly=${hourly}`)
}
// Вызываем функцию где-то в нашем приложении
const response = getWeather({
latitude: 52.52,
longitude: 13.41,
hourly: 'temperature_2m',
start_date: '2021-01-01',
end_date: '2021-01-03',
})
Все работает, мы получили данные в ответе. Теперь другая ситуация. Мы интегрируем эту функцию с виджетом календаря, который возвращает нам две даты типа Date.
function onDateChange(start_date: Date, end_date: Date) {
const response = getWeather({
latitude: 52.52,
longitude: 13.41,
hourly: 'temperature_2m',
start_date: start_date.toISOString(), // приводим Date к строковому представлению, в соответствии объявленному типу выше
end_date: end_date.toISOString(),
})
console.log(response)
}
Запускаем, наш код выполняет запрос https://archive-api.open-meteo.com/v1/era5?latitude=52.52&longitude=13.41&start_date=2023-01-01T20:00:00.000Z&end_date=2023-01-07T20:00:00.000Z&hourly=temperature_2m, и мы получаем ответ с ошибкой из API - {"error":true,"reason":"Invalid date format. Make sure to use 'YYYY-MM-DD'"}. Оказывается, нужно передавать дату в строгом формате YYYY-MM-DD, но наш тип start_date: string, end_date: string пропускает любую строку. В этом месте у нас runtime разошелся с compile-time. Для компилятора, передача любой строки в функцию getWeather считается корректной, но в реальной жизни это не так. Как мы можем решить эту проблему?
Сразу скажу, что вариант писать комментарии к функции или к отдельным ее аргументам, показывая пример какой именно формат даты можно передавать - плохая идея. Мы полностью доверяемся человеческому фактору и начинаем общаться внутри команды некими "тайными записками", в надежде, что их кто-нибудь прочитает. Поэтому нам нужна статическая проверка в compile-time, которая будет отличать разные форматы внутри одного примитивного типа (String) и обрабатывать их как несовместимые.
Мы хотим чтобы все даты в формате YYYY-MM-DD были отдельным типом, который был бы не равен типу string, но в то же время вел себя как string, если мы хотим использовать его для конкатенации строк или приведению в lower или upper-case.
Номинальные типы
Если мы просто создадим отдельный тип от строки, то нужного эффекта не будет.
type DateString = string
type GetWeatherRequestParams = {
...
start_date: DateString
end_date: DateString
}
...
function onDateChange(start_date: Date, end_date: Date) {
const response = getWeather({
...
start_date: start_date.toISOString(), // Все еще нет ошибки в compile-time
end_date: end_date.toISOString(),
})
}
Чтобы сделать тип DateString несовместимым с типом string, нужно подмешать в него что-то уникальное, например вот так:
declare const NominalType: unique symbol
type DateString = string & { readonly [NominalType]: 'DateString' }
...
function onDateChange(start_date: Date, end_date: Date) {
const response = getWeather({
...
start_date: start_date.toISOString(), // TS падает с ошибкой в compile-time - `Тип "string" не может быть назначен для типа "DateString"`
end_date: end_date.toISOString(),
})
}
Мы создали номинальный тип DateString, который является уникальным по отношению к базовому типу string. Теперь в функцию getWeather() нельзя передать в качестве дат случайные строки. Но где нам теперь брать даты в нужном формате и с нужным типом?
Чтобы сделать работу с датами в нашем приложении полностью безопасной, необходимо добавить хелпер для приведения даты из стандартного формата в DateString. Этот хелпер должен быть единственным источником дат в формате DateString во всем приложении. Другими словами, у разработчика на нашем проекте не должно быть другого варианта, кроме вызова этого хелпера, если в коде какая-то из функций требует на вход дату в формате DateString.
// Объявляем общий хелпер
function dateToDateString(date: Date): DateString {
const year = date.getFullYear()
const month = date.getMonth().toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}` as DateString // это будет единственное место в нашем проекте, где мы используем кастинг as DateString
}
...
function onDateChange(start_date: Date, end_date: Date) {
const response = getWeather({
...
start_date: dateToDateString(start_date), // преобразуем Date в DateString - нет ошибки в compile-time
end_date: dateToDateString(end_date),
})
}
Теперь у нас есть прочный мост между compile-time и runtime. Если мы где-то в приложении ожидаем в runtime дату строго в формате YYYY-MM-DD - компилятор не позволит нам собрать проект, пока мы не убедимся при помощи хелпера dateToDateString(), что мы преобразовали дату в нужный формат.
Другие примеры
- Добавляем номинальные типы в стандартную библиотеку TS или внешние пакеты
export type ISODateString = string & { readonly [NominalType]: 'ISODateString' } // объявляем отдельный тип для дат в формате 2023-01-07T20:00:00.000Z
declare global {
interface Date {
toISOString(): ISODateString // типизируем стандартный класс Date как источник дат в формате ISODateString
}
}
declare module 'moment' {
interface Moment {
toISOString(keepOffset?: boolean): ISODateString // типизируем библиотеку moment.js
}
}
const date3: ISODateString = new Date().toString() // TS Error - Тип "string" не может быть назначен для типа "ISODateString"
const date2: ISODateString = new Date().toDateString() // TS Error - Тип "string" не может быть назначен для типа "ISODateString"
const date1: ISODateString = new Date().toISOString() // корректно
const date4: ISODateString = moment().toString() // TS Error - Тип "string" не может быть назначен для типа "ISODateString"
const date5: ISODateString = moment().toISOString() // корректно
- Делаем id разных сущностей уникальными, чтобы нельзя было по ошибке передать id от одной сущности в функцию, которая ожидает id совершенно другой сущности.
declare const NominalType: unique symbol
type NominalString<TypeIdentifier> = string & { readonly [NominalType]: TypeIdentifier }
type ProjectId = NominalString<'ProjectId'>
type Project = {
id: ProjectId
name: string
}
type ProductId = NominalString<'ProductId'>
type Product = {
id: ProductId
description: string
}
function getProductPhotos(id: ProductId) {...}
const project: Project = {...} // сокращаю детали для наглядности
const product: Product = {...}
const photos1 = getProductPhotos(project.id) // TS Error: Аргумент типа "ProjectId" нельзя назначить параметру типа "ProductId".
const photos2 = getProductPhotos(product.id) // ошибки нет, тип соответствует
- Делаем работу с JSON-сроками более безопасной.
type JSONstring<T extends unknown> = NominalString<'JSONstring'>
function encodeJSON<T extends unknown>(data: T) {
return JSON.stringify(data) as JSONstring<T>
}
function parseJSON<T extends unknown>(string: JSONstring<T>) {
return JSON.parse(string) as T
}
...
const productJSON = encodeJSON(product) // Тип productJSON автоматически вывелся как JSONstring<Product>. Это уже строка, но TS будет помнить что в ней закодирован объект типа Product
const parsedProduct = parseJSON(productJSON) // Тип parsedProduct автоматически вывелся как Product, что полностью соответствует рантайму.
Теперь нам не нужно делать кастинг as при вызовах encodeJSON() и parseJSON(). Для encodeJSON() источником правды является исходный тип, который мы в него передаем. Для parseJSON() источником правды является результат encodeJSON(), который запомнил исходный тип структуры, которую он кодировал.
Заключение
Таким образом, использование номинальных типов позволяет нам сократить разрыв между runtime и compile time в тех местах, где мы обычно оставляем пространство для ошибок и надеемся на человеческий фактор. Это дешевый и довольно эффективный способ перенести дополнительные проверки на TS, сделав кодовую базу чище и надежней.
Пишите в комментариях свое мнение на данную тему и насколько вам в целом интересно продолжение цикла статей про TypeScript.

