Skip to main content

Command Palette

Search for a command to run...

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

Updated
6 min read
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(), что мы преобразовали дату в нужный формат.

Другие примеры

  1. Добавляем номинальные типы в стандартную библиотеку 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() // корректно
  1. Делаем 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) // ошибки нет, тип соответствует
  1. Делаем работу с 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.

More from this blog

Найм в IT. Как правильно проводить технические собеседования?

Ранее я описывал свое видение организации процесса найма и построения команды в общих чертах. В данной статье хочу рассказать подробнее про этап технического интервью. Тема горячая и всегда бурно обсуждаемая в комьюнити. Споры про "Зачем спрашивать а...

Jul 2, 20239 min read
Найм в IT. Как правильно проводить технические собеседования?

Релокация в Дубай. Фриланс виза

Данную статью можно рассматривать как полный гайд по релокации в Дубай для тех, кто работает удаленно или на себя как фрилансер. Мой процесс легализации завершился в марте 2023, но я планирую в будущем поддерживать актуальность гайда, обновляя информ...

Apr 1, 20236 min read
Релокация в Дубай. Фриланс виза

TypeScript Patterns. Строковые шаблоны

Продолжаем цикл статей про лучшие практики типизации в TypeScript. Сегодня разберем примеры использования Template Literal Types, не самой популярной и, на мой взгляд, сильно недооцененной фичи языка. Пример. Уровень - easy Короткий пример, аналогичн...

Feb 7, 20233 min read

Найм в IT: Построение процесса

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

Jan 29, 202310 min read
U

Untitled Publication

6 posts

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