# 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](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 интерфейс функции, которая будет выполнять этот запрос? Обычно это делают следующим образом:

```typescript
// Объявляем функцию и тип параметров
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`.

```typescript
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](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.

## Номинальные типы

Если мы просто создадим отдельный тип от строки, то нужного эффекта не будет.

```typescript
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`, нужно подмешать в него что-то уникальное, например вот так:

```typescript
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`.

```typescript
// Объявляем общий хелпер
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 или внешние пакеты
    

```typescript
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 совершенно другой сущности.
    

```typescript
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-сроками более безопасной.
    

```typescript
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.
