Generic типы позволяют писать универсальные обёртки поверх timeago.js, которые сохраняют типовую информацию через цепочку преобразований. Это особенно полезно при работе с коллекциями объектов, содержащих поля дат.
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
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 }>
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
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
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) },
};
}
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>();
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>}
/>
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 };
}
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);
Функция 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 делает
функцию одновременно гибкой и безопасной.