Создание типизированных оберток

Несмотря на наличие встроенных типов в date-fns, в крупных проектах быстро появляются проблемы:

  • разные части приложения по-разному работают с датами;
  • в коде смешиваются Date, строки и timestamp;
  • функции получают неподходящие значения;
  • форматирование становится несогласованным;
  • логика времени размазывается по всему проекту.

Типизированные обёртки позволяют:

  • централизовать работу с датами;
  • ограничить допустимые типы;
  • скрыть сложную логику;
  • повысить читаемость;
  • улучшить автодополнение TypeScript;
  • уменьшить количество runtime-ошибок.

Базовая проблема работы с датами

Даже при использовании date-fns можно легко написать опасный код:

import { format } from 'date-fns'

format('2025-01-01' as any, 'yyyy-MM-dd')

TypeScript не всегда способен защитить приложение от неправильных значений, особенно если данные приходят:

  • из API;
  • из localStorage;
  • из форм;
  • из query-параметров;
  • из внешних SDK.

В результате появляются:

Invalid time value

или:

RangeError: Invalid time value

Типизированная обёртка позволяет стандартизировать входные данные.


Простая типизированная обёртка

Обёртка над format

import { format } from 'date-fns'

export function formatDate(
  date: Date,
  pattern: string = 'yyyy-MM-dd'
): string {
  return format(date, pattern)
}

Использование:

formatDate(new Date())

Теперь передача строки станет ошибкой компиляции:

formatDate('2025-01-01')

TypeScript:

Argument of type 'string' is not assignable to parameter of type 'Date'

Ограничение шаблонов форматирования

Одна из самых полезных техник — создание набора допустимых шаблонов.

Проблема произвольных строк

Без ограничений:

format(date, 'abcxyz')

Ошибка появится только во время выполнения.


Создание union-типа

type DateFormat =
  | 'yyyy-MM-dd'
  | 'dd.MM.yyyy'
  | 'HH:mm'
  | 'dd.MM.yyyy HH:mm'

Теперь создаётся безопасная обёртка:

import { format } from 'date-fns'

export function safeFormat(
  date: Date,
  pattern: DateFormat
): string {
  return format(date, pattern)
}

Использование:

safeFormat(new Date(), 'dd.MM.yyyy')

Ошибка:

safeFormat(new Date(), 'random-format')

Создание семантических функций

Вместо передачи форматов вручную создаются функции с конкретным назначением.

Плохой вариант

format(date, 'dd.MM.yyyy')
format(date, 'dd.MM.yyyy')
format(date, 'dd.MM.yyyy')

Формат размазан по проекту.


Хороший вариант

import { format } from 'date-fns'

export function formatShortDate(date: Date): string {
  return format(date, 'dd.MM.yyyy')
}

export function formatTime(date: Date): string {
  return format(date, 'HH:mm')
}

export function formatApiDate(date: Date): string {
  return format(date, 'yyyy-MM-dd')
}

Преимущества:

  • единая точка изменения;
  • читаемые названия;
  • отсутствие дублирования;
  • контроль форматов.

Типизация строковых дат

Во многих проектах даты приходят строками.

Проблема

const date = 'hello world'

Тип:

string

TypeScript не понимает, что это не дата.


Брендированные типы

TypeScript позволяет создавать псевдо-уникальные типы.

Создание DateString

type DateString = string & {
  readonly __brand: 'DateString'
}

Функция-парсер

import { parseISO, isValid } from 'date-fns'

export function createDateString(
  value: string
): DateString {
  const parsed = parseISO(value)

  if (!isValid(parsed)) {
    throw new Error('Invalid date string')
  }

  return value as DateString
}

Использование:

const date = createDateString('2025-05-01')

Теперь тип:

DateString

Обёртка для parseISO

Небезопасный вариант

parseISO(anyUnknownString)

Типизированный вариант

import { parseISO } from 'date-fns'

export function parseDate(
  value: DateString
): Date {
  return parseISO(value)
}

Теперь невозможно случайно передать обычную строку.


Nullable-обёртки

Работа с API часто подразумевает nullable-поля.

Проблема

format(user.birthDate, 'yyyy-MM-dd')

Если birthDate === null, приложение упадёт.


Безопасная обёртка

import { format } from 'date-fns'

export function formatNullableDate(
  date: Date | null | undefined
): string {
  if (!date) {
    return ''
  }

  return format(date, 'dd.MM.yyyy')
}

Generic-обёртки

TypeScript позволяет создавать универсальные типизированные функции.

Пример

type WithDate = {
  createdAt: Date
}

Универсальная сортировка

import { compareDesc } from 'date-fns'

export function sortByCreatedAt<T extends WithDate>(
  items: T[]
): T[] {
  return [...items].sort((a, b) =>
    compareDesc(a.createdAt, b.createdAt)
  )
}

Использование:

const posts = sortByCreatedAt(postsData)
const comments = sortByCreatedAt(commentsData)

Типизация диапазонов дат

Проблема

Два независимых параметра:

function getStatistics(start: Date, end: Date)

Неочевидно:

  • что относится к диапазону;
  • можно ли перепутать аргументы;
  • есть ли ограничения.

Создание DateRange

type DateRange = {
  start: Date
  end: Date
}

Обёртка

import { isAfter } from 'date-fns'

export function createDateRange(
  start: Date,
  end: Date
): DateRange {
  if (isAfter(start, end)) {
    throw new Error('Invalid range')
  }

  return { start, end }
}

Работа с диапазонами

import { eachDayOfInterval } from 'date-fns'

export function getRangeDays(
  range: DateRange
): Date[] {
  return eachDayOfInterval(range)
}

Типизация UTC-дат

Одна из самых сложных проблем — смешивание локального времени и UTC.

Ошибочный код

const date = new Date()

Непонятно:

  • UTC это или local time;
  • можно ли отправлять на сервер;
  • корректно ли отображение.

Создание UTCDate

type UTCDate = Date & {
  readonly __brand: 'UTCDate'
}

Конструктор UTCDate

export function createUTCDate(
  value: string
): UTCDate {
  return new Date(value) as UTCDate
}

Отдельные функции для UTC

import { formatISO } from 'date-fns'

export function formatUTCDate(
  date: UTCDate
): string {
  return formatISO(date)
}

Теперь нельзя случайно передать локальную дату.


Обёртки над add/sub

Проблема

add(date, {
  days: 30
})

Непонятно:

  • бизнес-дни это или календарные;
  • используется ли UTC;
  • можно ли передавать отрицательные значения.

Семантические функции

import { addDays } from 'date-fns'

export function addTrialPeriod(
  date: Date
): Date {
  return addDays(date, 14)
}

Типизированные единицы времени

Ограничение допустимых unit

type TimeUnit =
  | 'days'
  | 'hours'
  | 'minutes'

Безопасное добавление времени

import { add } from 'date-fns'

type DurationMap = {
  days?: number
  hours?: number
  minutes?: number
}

export function addDuration(
  date: Date,
  duration: DurationMap
): Date {
  return add(date, duration)
}

Типизированные factory-функции

Создание доменных дат

В больших приложениях даты имеют контекст.

Примеры:

  • дата регистрации;
  • дата оплаты;
  • дата публикации;
  • дедлайн;
  • дата доставки.

Брендирование доменных дат

type PaymentDate = Date & {
  readonly __brand: 'PaymentDate'
}

type DeliveryDate = Date & {
  readonly __brand: 'DeliveryDate'
}

Factory-функции

export function createPaymentDate(
  value: Date
): PaymentDate {
  return value as PaymentDate
}

export function createDeliveryDate(
  value: Date
): DeliveryDate {
  return value as DeliveryDate
}

Защита бизнес-логики

function processPayment(date: PaymentDate) {
  // ...
}

Теперь невозможно случайно передать:

DeliveryDate

Обёртки для безопасного парсинга

Проблема parseISO

parseISO('invalid')

Вернёт:

Invalid Date

что опасно.


Result-подход

type ParseResult =
  | {
      success: true
      value: Date
    }
  | {
      success: false
      error: string
    }

Безопасный parser

import { parseISO, isValid } from 'date-fns'

export function safeParseISO(
  value: string
): ParseResult {
  const date = parseISO(value)

  if (!isValid(date)) {
    return {
      success: false,
      error: 'Invalid ISO date'
    }
  }

  return {
    success: true,
    value: date
  }
}

Создание date-service

В крупных приложениях часто создаётся единый слой работы с датами.

Структура

shared/
  lib/
    date/
      format.ts
      parse.ts
      compare.ts
      utc.ts
      range.ts

Централизованный API

export const dateService = {
  formatShortDate,
  formatTime,
  parseDate,
  safeParseISO,
  addTrialPeriod,
}

Использование:

dateService.formatShortDate(new Date())

Типизация timezone

Создание timezone-типа

type Timezone =
  | 'UTC'
  | 'Europe/Moscow'
  | 'Asia/Almaty'

Обёртка форматирования

type ZonedDateOptions = {
  timezone: Timezone
}

Пример API

function formatZonedDate(
  date: Date,
  options: ZonedDateOptions
)

Такая структура делает timezone обязательным параметром.


Интеграция с date-fns-tz

При использовании date-fns-tz типизированные обёртки особенно полезны.

Пример

import { formatInTimeZone } from 'date-fns-tz'

export function formatUserDate(
  date: Date,
  timezone: Timezone
): string {
  return formatInTimeZone(
    date,
    timezone,
    'dd.MM.yyyy HH:mm'
  )
}

Immutable-обёртки

Хотя date-fns уже работает иммутабельно, обёртки помогают явно фиксировать контракт.

Пример

type ImmutableDate = Readonly<Date>

Функция

import { addDays } from 'date-fns'

export function addImmutableDays(
  date: ImmutableDate,
  amount: number
): ImmutableDate {
  return addDays(date, amount)
}

Типизированные predicates

Проверки дат

import { isBefore, isAfter } from 'date-fns'

export function isExpired(
  expiresAt: Date
): boolean {
  return isBefore(expiresAt, new Date())
}

export function isFutureDate(
  date: Date
): boolean {
  return isAfter(date, new Date())
}

Такие функции:

  • читаются лучше;
  • скрывают детали реализации;
  • уменьшают дублирование.

Создание strict API

Проблема

function updateUser(data: any)

Типизированная модель

type User = {
  createdAt: Date
  updatedAt: Date
}

Обёртка обновления

function updateUserDates(
  user: User
): User {
  return {
    ...user,
    updatedAt: new Date(),
  }
}

Runtime-валидация и TypeScript

TypeScript не защищает runtime.

Поэтому типизированные обёртки почти всегда комбинируются с:

  • isValid;
  • zod;
  • io-ts;
  • valibot;
  • пользовательскими type guard.

Type Guard для Date

export function isDate(
  value: unknown
): value is Date {
  return (
    value instanceof Date &&
    !isNaN(value.getTime())
  )
}

Безопасная обёртка

import { format } from 'date-fns'

export function tryFormatDate(
  value: unknown
): string {
  if (!isDate(value)) {
    return ''
  }

  return format(value, 'dd.MM.yyyy')
}

Композиция обёрток

Обёртки особенно эффективны при комбинировании.

Пример

const date = createDateString(
  '2025-05-01'
)

const parsed = parseDate(date)

const formatted =
  formatShortDate(parsed)

Каждый этап:

  • строго типизирован;
  • валидирован;
  • предсказуем.

Паттерн Adapter

Типизированные обёртки часто используются как адаптер между:

  • API;
  • UI;
  • базой данных;
  • внешними библиотеками.

Пример адаптера API

type ApiUser = {
  createdAt: string
}

Domain-модель

type User = {
  createdAt: Date
}

Адаптер

import { parseISO } from 'date-fns'

export function mapUser(
  apiUser: ApiUser
): User {
  return {
    createdAt: parseISO(
      apiUser.createdAt
    ),
  }
}

Инкапсуляция date-fns

Обёртки уменьшают связанность проекта с библиотекой.

Если позже потребуется:

  • Day.js;
  • Luxon;
  • Temporal API;
  • собственная реализация;

изменения затронут только слой адаптеров.


Архитектурный подход

Наиболее устойчивый подход:

UI
 ↓
typed wrappers
 ↓
date-fns

В таком случае:

  • UI не знает о деталях date-fns;
  • форматирование стандартизировано;
  • timezone контролируются;
  • парсинг централизован;
  • бизнес-логика становится предсказуемой;
  • TypeScript начинает работать значительно эффективнее.