Работа с generic типами

Generic типы позволяют писать универсальные обёртки поверх timeago.js, которые сохраняют типовую информацию через цепочку преобразований. Это особенно полезно при работе с коллекциями объектов, содержащих поля дат.


Generic обёртка для одного объекта

import { format } from 'timeago.js';

interface WithDate {
  createdAt: string | Date | number;
}

function addTimeAgo<T extends WithDate>(
  item: T,
  locale = 'ru'
): T & { timeAgo: string } {
  return {
    ...item,
    timeAgo: format(item.createdAt, locale),
  };
}

// TypeScript знает все поля оригинального объекта
const post = addTimeAgo({ id: 1, title: 'Тест', createdAt: '2025-01-01' });
post.id;      // number
post.title;   // string
post.timeAgo; // string

Generic обёртка для коллекций

import { format } from 'timeago.js';

function enrichWithTimeAgo<T extends { createdAt: string | Date | number }>(
  items: T[],
  locale = 'ru'
): Array<T & { timeAgo: string }> {
  return items.map(item => ({
    ...item,
    timeAgo: format(item.createdAt, locale),
  }));
}

// Использование
interface Post { id: number; title: string; createdAt: string; }
const posts: Post[] = [
  { id: 1, title: 'Первый', createdAt: '2025-01-01' },
];
const enriched = enrichWithTimeAgo(posts);
// Тип: Array<Post & { timeAgo: string }>

Generic с выбором поля даты

import { format } from 'timeago.js';

function formatField<T, K extends keyof T>(
  item: T,
  dateField: K,
  locale = 'ru'
): T & { timeAgo: string } {
  const date = item[dateField] as unknown as Date | string | number;
  return { ...item, timeAgo: format(date, locale) };
}

// Использование — TypeScript проверяет, что поле существует
const user = { name: 'Иван', registeredAt: '2024-06-01', age: 30 };
const result = formatField(user, 'registeredAt');
// formatField(user, 'unknown') — ошибка TypeScript

Generic форматирование нескольких полей

import { format } from 'timeago.js';

type DateFields<T> = {
  [K in keyof T]: T[K] extends Date | string | number ? K : never;
}[keyof T];

type WithFormattedFields<T, Keys extends keyof T> = T & {
  [K in Keys as `${string & K}Ago`]: string;
};

function formatDateFields<T, K extends DateFields<T>>(
  item: T,
  fields: K[],
  locale = 'ru'
): WithFormattedFields<T, K> {
  const extra: Record<string, string> = {};
  fields.forEach(field => {
    const value = item[field] as unknown as Date | string | number;
    extra[`${String(field)}Ago`] = format(value, locale);
  });
  return { ...item, ...extra } as WithFormattedFields<T, K>;
}

// Использование
const comment = { id: 1, text: 'Привет', createdAt: '2025-01-01', upd atedAt: '2025-02-01' };
const result = formatDateFields(comment, ['createdAt', 'updatedAt']);
// result.createdAtAgo: string
// result.updatedAtAgo: string

Generic Result тип

import { format } from 'timeago.js';

type Ok<T>  = { success: true;  dat a: T };
type Err<E> = { success: false; error: E };
type Result<T, E = string> = Ok<T> | Err<E>;

function tryFormatGeneric<T extends Record<string, unknown>>(
  item: T,
  dateKey: keyof T,
  locale = 'ru'
): Result<T & { timeAgo: string }> {
  const rawDate = item[dateKey];

  if (rawDate == null) {
    return { success: false, error: `Field "${String(dateKey)}" is null` };
  }

  const d = new Date(rawDate as any);
  if (isNaN(d.getTime())) {
    return { success: false, error: `Invalid date in field "${String(dateKey)}"` };
  }

  return {
    success: true,
    data: { ...item, timeAgo: format(d, locale) },
  };
}

Generic локальный реестр

import { register, format } from 'timeago.js';
import type { LocaleFunc } from 'timeago.js';

class LocaleRegistry<T extends string> {
  private readonly locales = new Map<T, LocaleFunc>();

  register(key: T, fn: LocaleFunc): void {
    this.locales.se t(key, fn);
    register(key, fn);
  }

  has(key: string): key is T {
    return this.locales.has(key as T);
  }

  format(date: Date | string | number, locale: T): string {
    if (!this.has(locale)) {
      throw new Error(`Locale "${locale}" not registered`);
    }
    return format(date, locale);
  }

  keys(): T[] {
    return Array.from(this.locales.keys());
  }
}

type AppLocale = 'ru' | 'en_US' | 'de';
const registry = new LocaleRegistry<AppLocale>();

Generic компонент React

import { format } from 'timeago.js';

type HasCreatedAt = { createdAt: Date | string | number };

interface TimeAgoListProps<T extends HasCreatedAt> {
  items:     T[];
  locale?:   string;
  renderItem: (item: T & { timeAgo: string }, index: number) => React.ReactNode;
}

function TimeAgoList<T extends HasCreatedAt>({
  items,
  locale = 'ru',
  renderItem,
}: TimeAgoListProps<T>) {
  return (
    <ul>
      {items.map((item, i) =>
        renderItem({ ...item, timeAgo: format(item.createdAt, locale) }, i)
      )}
    </ul>
  );
}

// Использование — T выводится автоматически
<TimeAgoList
  items={posts}
  renderItem={(post, i) => <li key={i}>{post.title} — {post.timeAgo}</li>}
/>

Generic хук

import { useState, useEffect } from 'react';
import { format } from 'timeago.js';

function useTimeAgoField<T extends Record<string, unknown>>(
  item: T,
  field: { [K in keyof T]: T[K] extends Date | string | number ? K : never }[keyof T],
  locale = 'ru'
): T & { timeAgo: string } {
  const [timeAgo, setTimeAgo] = useState(() =>
    format(item[field] as any, locale)
  );

  useEffect(() => {
    const id = setInterval(() => {
      setTimeAgo(format(item[field] as any, locale));
    }, 30_000);
    return () => clearInterval(id);
  }, [item, field, locale]);

  return { ...item, timeAgo };
}

Generic функция трансформации

import { format } from 'timeago.js';

type Transformer<T, U> = (item: T) => U;

function createTimeAgoTransformer<T extends { createdAt: string }>(
  locale = 'ru'
): Transformer<T, T & { timeAgo: string }> {
  return (item: T) => ({
    ...item,
    timeAgo: format(item.createdAt, locale),
  });
}

// Создаётся один раз и переиспользуется
const toRussianTimeAgo = createTimeAgoTransformer('ru');
const results = rawPosts.map(toRussianTimeAgo);

Ограничения Generic при работе с timeago.js

Функция format принимает Date | string | number, но не unknown. При обобщённых типах нужен явный каст:

// Неправильно — ошибка TypeScript
function formatAny<T>(date: T): string {
  return format(date); // Ошибка: T не совместим с DateInput
}

// Правильно — сужение типа
function formatSafe<T extends Date | string | number>(date: T): string {
  return format(date); // OK
}

Ограничение T extends Date | string | number делает функцию одновременно гибкой и безопасной.