Примеры решений в TypeScript
1. Типизация и валидация данных
1.1. Утилиты типизации: DeepPartial, NonNullableProps, PickByValue
// DeepPartial<T> — рекурсивно делает все поля необязательными
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? DeepPartial<U>[]
: T[P] extends object | undefined
? DeepPartial<T[P]>
: T[P];
};
// NonNullableProps<T> — выбирает только поля, тип которых не включает null | undefined
type NonNullableProps<T> = {
[P in keyof T as null extends T[P] ? never : undefined extends T[P] ? never : P]: T[P];
};
// PickByValue<T, V> — выбирает поля, у которых тип совместим со значением V
type PickByValue<T, V> = Pick<T, { [K in keyof T]: T[K] extends V ? K : never }[keyof T]>;
// Пример использования:
interface User {
id: number;
name: string | null;
email: string;
settings?: {
theme: 'light' | 'dark';
notifications: boolean | null;
};
}
type UserPartial = DeepPartial<User>; // все поля и вложенные — опциональны
type RequiredFields = NonNullableProps<User>; // id, email
type StringFields = PickByValue<User, string>; // { name: string | null, email: string }
1.2. Runtime-валидация с узкими типами (без внешних библиотек)
// Примитивный валидатор с narrowing-гарантией
function isString(x: unknown): x is string {
return typeof x === 'string';
}
function isNumber(x: unknown): x is number {
return typeof x === 'number' && isFinite(x);
}
function isArray<T>(
arr: unknown,
guard: (item: unknown) => item is T
): arr is T[] {
return Array.isArray(arr) && arr.every(guard);
}
// Пример парсинга структуры с гарантией типа
interface ApiResponse {
users: { id: number; name: string }[];
timestamp: number;
}
function parseApiResponse(data: unknown): ApiResponse | null {
if (typeof data !== 'object' || data === null) return null;
const { users, timestamp } = data as Record<string, unknown>;
if (!isNumber(timestamp)) return null;
if (!isArray(users, (u): u is { id: number; name: string } =>
typeof u === 'object' && u !== null &&
isNumber((u as any).id) &&
isString((u as any).name)
)) return null;
return { users, timestamp };
}
✅ Такой подход даёт полную типобезопасность без
anyи безzod/io-ts. Подходит для лёгких сценариев или когда внешние зависимости нежелательны.
2. Работа с асинхронностью
2.1. Параллельное выполнение с ограничением параллелизма (throttled Promise.all)
async function throttlePromises<T>(
tasks: (() => Promise<T>)[],
maxConcurrency: number
): Promise<T[]> {
const results: T[] = new Array(tasks.length);
const active = new Set<Promise<void>>();
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const p = task()
.then(res => { results[i] = res; })
.finally(() => active.delete(p));
active.add(p);
if (active.size >= maxConcurrency && i < tasks.length - 1) {
await Promise.race(active);
}
}
await Promise.all(active);
return results;
}
// Пример:
const urls = Array.from({ length: 100 }, (_, i) => `https://api.example.com/item/${i}`);
const fetchers = urls.map(url => () => fetch(url).then(r => r.json()));
const data = await throttlePromises(fetchers, 5); // максимум 5 одновременных запросов
2.2. Retry-логика с экспоненциальной задержкой
type RetryOptions = {
maxAttempts?: number;
baseDelayMs?: number;
factor?: number;
jitter?: boolean;
};
async function retry<T>(
fn: () => Promise<T>,
{ maxAttempts = 3, baseDelayMs = 100, factor = 2, jitter = true }: RetryOptions = {}
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (attempt === maxAttempts) break;
const delay = baseDelayMs * Math.pow(factor, attempt - 1);
const jittered = jitter ? delay * (0.5 + Math.random() * 0.5) : delay;
await new Promise(r => setTimeout(r, jittered));
}
}
throw lastError;
}
// Пример использования:
const result = await retry(() => unreliableApiCall(), {
maxAttempts: 5,
baseDelayMs: 200,
jitter: true
});
3. Утилиты для коллекций
3.1. Группировка, индексация и фильтрация
// groupBy — возвращает Record<K, T[]>
function groupBy<T, K extends string | number | symbol>(
arr: T[],
keySelector: (item: T) => K
): Record<K, T[]> {
return arr.reduce((acc, item) => {
const key = keySelector(item);
(acc[key] ??= []).push(item);
return acc;
}, {} as Record<K, T[]>);
}
// indexBy — возвращает Record<K, T> (последний при дублях)
function indexBy<T, K extends string | number | symbol>(
arr: T[],
keySelector: (item: T) => K
): Record<K, T> {
return Object.fromEntries(
arr.map(item => [keySelector(item), item])
) as Record<K, T>;
}
// distinctBy — уникальные элементы по ключу
function distinctBy<T, K>(arr: T[], keySelector: (item: T) => K): T[] {
const seen = new Set<K>();
return arr.filter(item => {
const key = keySelector(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Пример:
interface Order { id: number; customerId: string; amount: number; }
const orders: Order[] = [...];
const byCustomer = groupBy(orders, o => o.customerId);
const byId = indexBy(orders, o => o.id);
const uniqueCustomers = distinctBy(orders, o => o.customerId);
3.2. Безопасный доступ к вложенным свойствам (get à la Lodash)
type PathSegment = string | number;
type Path = PathSegment | PathSegment[];
function get<T, R = unknown>(
obj: T,
path: Path,
defaultValue?: R
): R | undefined {
const segments = Array.isArray(path) ? path : String(path).split(/[.[\]]+/).filter(Boolean);
let current: unknown = obj;
for (const seg of segments) {
if (current == null) return defaultValue as R | undefined;
if (typeof current !== 'object') return defaultValue as R | undefined;
// Поддержка числовых индексов для массивов/строк
const key = /^\d+$/.test(seg) ? Number(seg) : seg;
current = (current as Record<string | number, unknown>)[key];
}
return (current !== undefined ? current : defaultValue) as R | undefined;
}
// Примеры:
const data = { user: { profile: { name: 'Timur' } } };
get(data, 'user.profile.name'); // 'Timur'
get(data, ['user', 'settings', 'theme'], 'light'); // 'light'
get(data, 'user.orders[0].id'); // undefined → null-safe
✅ Поддерживает путь в виде строки (
"a.b[0].c") или массива (['a', 'b', 0, 'c']). Не используетevalи безопасен.
4. Работа с датами и временем (без внешних библиотек)
4.1. Утилиты на основе Date с сохранением временной зоны
// Форматирование ISO без сдвига (локальное время → строка вида "2025-11-13T15:30:00")
function toLocalISO(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// Разбор строки вида "2025-11-13T15:30:00" как *локального* времени
function fromLocalISO(iso: string): Date {
const [datePart, timePart] = iso.split('T');
const [Y, M, D] = datePart.split('-').map(Number);
const [h, m, s = 0] = (timePart?.split(':').map(Number) ?? [0, 0, 0]);
return new Date(Y, M - 1, D, h, m, s);
}
// Добавление интервалов (дни/часы/минуты), учитывающее DST локально
function addDuration(date: Date, duration: { days?: number; hours?: number; minutes?: number }): Date {
const result = new Date(date);
if (duration.days) result.setDate(result.getDate() + duration.days);
if (duration.hours) result.setHours(result.getHours() + duration.hours);
if (duration.minutes) result.setMinutes(result.getMinutes() + duration.minutes);
return result;
}
// Пример:
const now = new Date();
const tomorrow = addDuration(now, { days: 1 });
console.log(toLocalISO(tomorrow)); // "2025-11-14TXX:XX:XX"
⚠️ Эти функции работают в локальной временной зоне — подходят для UI/форм, где пользователь вводит дату в своём часовом поясе. Для UTC — использовать
toISOString()/new Date(Date.UTC(...)).
5. Работа с функциями: композиция, мемоизация, каррирование
5.1. Мемоизация с TTL и сбросом по ключу
function memoizeWithTTL<T extends (...args: any[]) => any>(
fn: T,
options: {
ttlMs?: number;
keyFn?: (...args: Parameters<T>) => string;
} = {}
): ((...args: Parameters<T>) => ReturnType<T>) & { clear: () => void } {
const { ttlMs = 60_000, keyFn = JSON.stringify } = options;
const cache = new Map<string, { value: ReturnType<T>; expiresAt: number }>();
const memoized = (...args: Parameters<T>): ReturnType<T> => {
const key = keyFn(...args);
const now = Date.now();
const cached = cache.get(key);
if (cached && cached.expiresAt > now) {
return cached.value;
}
const value = fn(...args);
cache.set(key, { value, expiresAt: now + ttlMs });
return value;
};
memoized.clear = () => cache.clear();
return memoized;
}
// Пример:
const fetchUser = memoizeWithTTL(
async (id: string) => (await fetch(`/api/users/${id}`)).json(),
{ ttlMs: 30_000, keyFn: id => `user:${id}` }
);
const user1 = await fetchUser('123'); // кэшируется на 30 сек
const user2 = await fetchUser('123'); // из кэша
fetchUser.clear(); // сброс
5.2. Композиция функций (pipe)
// Правый порядок: pipe(f1, f2, f3)(x) === f3(f2(f1(x)))
function pipe<A, B>(f1: (a: A) => B): (a: A) => B;
function pipe<A, B, C>(f1: (a: A) => B, f2: (b: B) => C): (a: A) => C;
function pipe<A, B, C, D>(f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D): (a: A) => D;
// Обобщённая сигнатура (можно расширить до 10+ аргументов)
function pipe(...fns: ((...args: any[]) => any)[]): (...args: any[]) => any {
return (input: any) => fns.reduce((acc, fn) => fn(acc), input);
}
// Пример:
const toUpper = (s: string) => s.toUpperCase();
const trim = (s: string) => s.trim();
const prepend = (prefix: string) => (s: string) => `${prefix}${s}`;
const normalizeName = pipe(trim, toUpper, prepend('USR_'));
normalizeName(' timur '); // → 'USR_TIMUR'
✅ Поддерживает inference при фиксированных перегрузках. Для большего числа аргументов — использовать
const pipe = (...fns) => ...иReturnType<typeof fns[number]>в обобщённой реализации.
6. Типобезопасные события (EventEmitter без string)
type EventMap = {
userLogin: { userId: string; timestamp: Date };
userLogout: { userId: string };
error: { code: number; message: string };
};
class TypedEventEmitter<M extends Record<string, any>> {
private listeners = new Map<keyof M, Set<(payload: M[keyof M]) => void>>();
on<E extends keyof M>(event: E, listener: (payload: M[E]) => void): void {
const set = this.listeners.get(event) ?? new Set();
set.add(listener as (payload: M[keyof M]) => void);
this.listeners.set(event, set);
}
off<E extends keyof M>(event: E, listener: (payload: M[E]) => void): void {
this.listeners.get(event)?.delete(listener as (payload: M[keyof M]) => void);
}
emit<E extends keyof M>(event: E, payload: M[E]): void {
this.listeners.get(event)?.forEach(fn => fn(payload as M[keyof M]));
}
}
// Использование:
const bus = new TypedEventEmitter<EventMap>();
bus.on('userLogin', ({ userId, timestamp }) => {
console.log(`User ${userId} logged in at`, timestamp);
});
bus.emit('userLogin', { userId: 't123', timestamp: new Date() }); // ✅ типобезопасно
// bus.emit('userLogin', { userId: 't123' }); // ❌ ошибка: нет timestamp
7. Работа с файловой системой (Node.js)
7.1. Безопасное чтение и запись JSON-файлов с fallback-значением
import * as fs from 'node:fs';
import * as path from 'node:path';
async function readJsonFile<T>(
filePath: string,
fallback?: T
): Promise<T> {
try {
const data = await fs.promises.readFile(filePath, 'utf-8');
return JSON.parse(data) as T;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT' && fallback !== undefined) {
return fallback;
}
throw err;
}
}
async function writeJsonFile<T>(
filePath: string,
data: T,
options: { spaces?: number; ensureDir?: boolean } = {}
): Promise<void> {
const { spaces = 2, ensureDir = true } = options;
if (ensureDir) {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
}
const content = JSON.stringify(data, null, spaces);
await fs.promises.writeFile(filePath, content, 'utf-8');
}
// Пример: сохранение конфига с миграцией схемы
interface AppConfigV1 { version: 1; port: number }
interface AppConfigV2 { version: 2; port: number; timeoutMs: number }
type AppConfig = AppConfigV2;
async function loadConfig(configPath: string): Promise<AppConfig> {
const raw = await readJsonFile<{ version?: number }>(configPath, { version: 1 });
switch (raw.version) {
case 1:
// миграция v1 → v2
return { version: 2, port: (raw as AppConfigV1).port, timeoutMs: 5000 };
case 2:
return raw as AppConfig;
default:
throw new Error(`Unsupported config version: ${raw.version}`);
}
}
✅ Поддерживает миграции, автосоздание директорий, fallback-значения. Позволяет избежать
try/catchв каждом месте вызова.
7.2. Поиск файлов по шаблону (аналог glob, но без зависимостей)
import * as fs from 'node:fs';
import * as path from 'node:path';
function globSync(pattern: string, cwd = process.cwd()): string[] {
const [dirPart, ...segments] = pattern.split(/[\/\\]/).filter(Boolean);
if (segments.length === 0) {
// простой случай: 'dir/*' → readdir + match
const files = fs.readdirSync(path.resolve(cwd, dirPart || '.'));
return files
.filter(f => minimatch(f, segments[0] ?? '*'))
.map(f => path.join(dirPart || '.', f));
}
// рекурсивная реализация для '**', но для краткости — простая версия
throw new Error('Only flat globs (e.g. "src/*.ts") are supported in this minimal version.');
}
// Минималистичный glob-паттерн (только *, ?, [a-z])
function minimatch(name: string, pattern: string): boolean {
const regex = new RegExp(
'^' + pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')
.replace(/\[([^\]]+)\]/g, '[$1]')
+ '$'
);
return regex.test(name);
}
// Пример:
const tsFiles = globSync('src/**/*.ts'); // требует расширения для '**'
// В продакшене рекомендуется `fast-glob`, но для лёгких случаев — свой glob приемлем.
📝 Замечание: для промышленного использования —
fast-globилиglob(npm), но в обучающем/ограниченном контексте полезно понимать, как это работает «под капотом».
8. Типизированные конфигурации
8.1. .env + Zod-валидация (с fallback и логами)
// config/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().positive().int().default(3000),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
ENABLE_METRICS: z.coerce.boolean().default(false),
});
type Env = z.infer<typeof EnvSchema>;
let env: Env;
export function loadEnv(): Env {
if (env) return env;
// Предварительная загрузка переменных (для поддержки default в Zod)
process.env.NODE_ENV ??= 'development';
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`);
throw new Error(`Invalid environment variables:\n${issues.join('\n')}`);
}
env = result.data;
return env;
}
// Использование:
const config = loadEnv();
console.log(config.PORT); // number, гарантированно валидный
✅ Гарантирует:
- обязательные поля (ошибка при старте);
- coercion (
"3000"→3000);- fallback-значения по умолчанию;
- кэширование, однократная инициализация.
8.2. Гибридная конфигурация: .env + config.json + CLI-аргументы
// config/index.ts
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { loadEnv } from './env';
const CliArgs = yargs(hideBin(process.argv))
.option('port', { type: 'number', alias: 'p' })
.option('log-level', { type: 'string', choices: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const })
.parseSync();
type Config = {
port: number;
logLevel: string;
databaseUrl: string;
enableMetrics: boolean;
};
export function getConfig(): Config {
const env = loadEnv();
return {
port: CliArgs.port ?? env.PORT,
logLevel: CliArgs['log-level'] ?? env.LOG_LEVEL,
databaseUrl: env.DATABASE_URL,
enableMetrics: env.ENABLE_METRICS,
};
}
🔁 Приоритет: CLI > .env > default. Идеально для сервисов, где часть параметров нужно переопределять в CI/деплое.
9. CLI-утилиты без «магии»
9.1. Прогресс-бар для длительных задач (чистый ANSI)
class ProgressBar {
private total: number;
private current = 0;
private width = 40;
private lastRender = 0;
constructor(total: number) {
this.total = total;
}
update(n: number = 1): void {
this.current += n;
const now = Date.now();
// не рисовать чаще 100 мс
if (now - this.lastRender < 100 && this.current < this.total) return;
this.lastRender = now;
this.render();
}
render(): void {
const percent = Math.min(100, Math.floor((this.current / this.total) * 100));
const filled = Math.floor((this.current / this.total) * this.width);
const bar = '█'.repeat(filled) + '░'.repeat(this.width - filled);
process.stdout.write(`\r[${bar}] ${percent}% (${this.current}/${this.total})`);
if (this.current >= this.total) process.stdout.write('\n');
}
}
// Пример:
const files = await fs.promises.readdir('large_dir');
const pb = new ProgressBar(files.length);
for (const file of files) {
await processFile(file);
pb.update();
}
🖥️ Работает в
bash,zsh, Windows Terminal. Безora,cli-progress— если нужна минимальная зависимость.
9.2. Вопросы с валидацией (аналог inquirer, но на readline)
import * as readline from 'node:readline';
async function askQuestion(
question: string,
validate?: (input: string) => string | true
): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise(resolve => {
const ask = () => {
rl.question(`${question} `, answer => {
const trimmed = answer.trim();
if (!validate) {
rl.close();
return resolve(trimmed);
}
const result = validate(trimmed);
if (result === true) {
rl.close();
resolve(trimmed);
} else {
console.log(`❌ ${result}`);
ask();
}
});
};
ask();
});
}
// Примеры:
const name = await askQuestion('Ваше имя:');
const email = await askQuestion('Email:', input =>
input.includes('@') && input.includes('.')
? true
: 'Введите корректный email'
);
console.log(`Привет, ${name}! Письма пришлём на ${email}.`);
✅ Полный контроль над валидацией, без внешней библиотеки. Подходит для лёгких скриптов и обучающих проектов.
10. Типобезопасный клиент API
10.1. Базовый клиент с retry, таймаутом и типизацией ответа
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
interface ApiClientOptions {
baseUrl: string;
timeoutMs?: number;
headers?: Record<string, string>;
retry?: { maxAttempts: number; delayMs?: number };
}
interface RequestConfig {
headers?: Record<string, string>;
query?: Record<string, string | number | boolean | null | undefined>;
body?: any;
timeoutMs?: number;
}
class ApiClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
private timeoutMs: number;
private retryOpts?: { maxAttempts: number; delayMs: number };
constructor(opts: ApiClientOptions) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
this.defaultHeaders = { 'Content-Type': 'application/json', ...opts.headers };
this.timeoutMs = opts.timeoutMs ?? 10_000;
this.retryOpts = opts.retry ? { delayMs: 300, ...opts.retry } : undefined;
}
private async request<T>(
method: HttpMethod,
path: string,
config: RequestConfig = {}
): Promise<T> {
const url = new URL(path, this.baseUrl);
if (config.query) {
Object.entries(config.query).forEach(([k, v]) => {
if (v != null) url.searchParams.set(k, String(v));
});
}
const headers = { ...this.defaultHeaders, ...config.headers };
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeoutMs ?? this.timeoutMs);
const init: RequestInit = { method, headers, signal: controller.signal };
if (config.body) init.body = JSON.stringify(config.body);
try {
const res = await fetch(url.toString(), init);
clearTimeout(timeoutId);
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const contentType = res.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await res.json() as T;
}
return (await res.text()) as unknown as T;
} catch (err) {
clearTimeout(timeoutId);
if ((err as Error).name === 'AbortError') {
throw new Error(`Request timed out (${config.timeoutMs ?? this.timeoutMs}ms)`);
}
throw err;
}
}
// Перегрузки для конкретных методов
get<T>(path: string, config?: RequestConfig): Promise<T> {
return this.request<T>('GET', path, config);
}
post<T>(path: string, config?: Omit<RequestConfig, 'query'>): Promise<T> {
return this.request<T>('POST', path, config);
}
// Пример с retry (можно вынести в декоратор)
async postWithRetry<T>(path: string, config: Omit<RequestConfig, 'query'> = {}): Promise<T> {
if (!this.retryOpts) return this.post<T>(path, config);
let lastError: unknown;
for (let i = 0; i <= this.retryOpts.maxAttempts; i++) {
try {
return await this.post<T>(path, config);
} catch (err) {
lastError = err;
if (i < this.retryOpts.maxAttempts) {
await new Promise(r => setTimeout(r, this.retryOpts!.delayMs));
}
}
}
throw lastError;
}
}
// Использование:
const api = new ApiClient({
baseUrl: 'https://api.example.com/v1',
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
retry: { maxAttempts: 3 }
});
interface User { id: string; name: string; email: string }
const user = await api.get<User>('/users/me', {
timeoutMs: 5000,
query: { include: 'profile' }
});
await api.postWithRetry('/events', { body: { type: 'login', userId: user.id } });
✅ Преимущества:
- типизация ответа на уровне вызова (
get<User>(...));- контроль заголовков, query, таймаута;
- retry «из коробки»;
- совместимость с
fetch-полифилами (node-fetch,undici).
11. Утилиты для тестирования (без фреймворка-зависимостей)
11.1. Мок зависимостей через Proxy (для модульных тестов)
// utils/mock.ts
export function createMock<T extends object>(base: T, overrides: Partial<T> = {}): T {
return new Proxy({ ...base, ...overrides }, {
get(target, prop) {
const value = target[prop as keyof T];
if (typeof value === 'function') {
return function (this: any, ...args: any[]) {
return value.apply(this, args);
};
}
return value;
},
set() {
throw new Error('Mocks are immutable');
}
}) as T;
}
// Пример: мокирование модуля fs/promises
const mockFs = createMock(
{ readFile: async () => '' } as typeof import('node:fs/promises'),
{
readFile: vi.fn().mockResolvedValue('{"test": true}'),
writeFile: vi.fn().mockResolvedValue(undefined)
}
);
// В тесте (vitest):
vi.mock('node:fs/promises', () => mockFs);
🧪 Подходит для интеграционных и юнит-тестов, где нужно частично заменить модуль.
12. Type-Safe React Hooks
12.1. useAsync — управление асинхронным состоянием
import { useEffect, useReducer, useRef } from 'react';
type AsyncState<T> =
| { status: 'idle'; data?: undefined; error?: undefined }
| { status: 'pending'; data?: undefined; error?: undefined }
| { status: 'resolved'; data: T; error?: undefined }
| { status: 'rejected'; data?: undefined; error: unknown };
type AsyncAction<T> =
| { type: 'start' }
| { type: 'resolve'; data: T }
| { type: 'reject'; error: unknown };
function asyncReducer<T>(state: AsyncState<T>, action: AsyncAction<T>): AsyncState<T> {
switch (action.type) {
case 'start': return { status: 'pending' };
case 'resolve': return { status: 'resolved', data: action.data };
case 'reject': return { status: 'rejected', error: action.error };
default: return state;
}
}
export function useAsync<T>(
fn: () => Promise<T>,
deps: React.DependencyList = [],
autoInvoke = true
): AsyncState<T> & {
execute: () => Promise<void>;
reset: () => void;
} {
const [state, dispatch] = useReducer(asyncReducer, { status: 'idle' });
const mountedRef = useRef(true);
useEffect(() => {
return () => { mountedRef.current = false; };
}, []);
const execute = async () => {
dispatch({ type: 'start' });
try {
const data = await fn();
if (mountedRef.current) dispatch({ type: 'resolve', data });
} catch (error) {
if (mountedRef.current) dispatch({ type: 'reject', error });
}
};
const reset = () => {
dispatch({ type: 'start' });
dispatch({ type: 'resolve', data: undefined as unknown as T }); // idle через resolved с undefined
// или можно ввести 'idle' action
};
useEffect(() => {
if (autoInvoke) execute();
}, deps);
return { ...state, execute, reset };
}
// Использование:
function UserProfile({ userId }: { userId: string }) {
const { status, data, error, execute } = useAsync(
() => fetch(`/api/users/${userId}`).then(r => r.json() as Promise<User>),
[userId]
);
if (status === 'pending') return <Spinner />;
if (status === 'rejected') return <ErrorBox error={error} onRetry={execute} />;
if (status === 'resolved') return <UserCard user={data} />;
return null; // idle (редко)
}
✅ Безопасен для unmount, поддерживает повторные вызовы, типизирован «до конца» —
dataдоступен только вresolved,error— только вrejected.
12.2. useDebounce и useThrottle с отменой
import { useEffect, useState, useRef } from 'react';
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
timeoutRef.current = window.setTimeout(() => setDebounced(value), delayMs);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [value, delayMs]);
return debounced;
}
export function useThrottle<T>(value: T, delayMs: number): T {
const [throttled, setThrottled] = useState(value);
const lastExecuted = useRef<number>(Date.now());
useEffect(() => {
const now = Date.now();
if (now - lastExecuted.current >= delayMs) {
lastExecuted.current = now;
setThrottled(value);
} else {
const id = window.setTimeout(() => {
lastExecuted.current = Date.now();
setThrottled(value);
}, delayMs - (now - lastExecuted.current));
return () => clearTimeout(id);
}
}, [value, delayMs]);
return throttled;
}
// Пример: поиск с debounce
function SearchBox() {
const [input, setInput] = useState('');
const debouncedQuery = useDebounce(input, 300);
const { data } = useAsync(() => searchApi(debouncedQuery), [debouncedQuery], !!debouncedQuery);
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
{data?.map(item => <Result key={item.id} item={item} />)}
</div>
);
}
⏱️ Оба хука отменяют таймеры при unmount, корректно работают при изменении
delayMs.
13. Работа с DOM и ref
13.1. useClickOutside — обнаружение кликов вне элемента
import { useEffect, RefObject } from 'react';
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T>,
handler: (event: MouseEvent | TouchEvent) => void,
options?: { enabled?: boolean }
): void {
const { enabled = true } = options ?? {};
useEffect(() => {
if (!enabled) return;
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler(event);
};
document.addEventListener('mousedown', listener, true);
document.addEventListener('touchstart', listener, true);
return () => {
document.removeEventListener('mousedown', listener, true);
document.removeEventListener('touchstart', listener, true);
};
}, [ref, handler, enabled]);
}
// Использование: dropdown
function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, () => setOpen(false), { enabled: open });
return (
<div ref={ref} className="dropdown">
<button onClick={() => setOpen(v => !v)}>Toggle</button>
{open && <Menu />}
</div>
);
}
✅ Поддерживает
touchstartдля мобильных, используетuseCapture: trueдля корректной работы при вложенных dropdown’ах.
13.2. useResizeObserver — реакция на изменение размера
export function useResizeObserver<T extends HTMLElement>(
ref: RefObject<T>,
callback: (entry: ResizeObserverEntry) => void
): void {
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(entries => {
if (entries[0]) callback(entries[0]);
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, callback]);
}
// Пример: адаптивный контейнер
function ResponsiveGrid() {
const ref = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3);
useResizeObserver(ref, entry => {
const width = entry.contentRect.width;
setColumns(width < 600 ? 1 : width < 900 ? 2 : 3);
});
return (
<div ref={ref} style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{items.map(item => <Card key={item.id} item={item} />)}
</div>
);
}
📏 Точнее, чем
window.onresize, так как реагирует на изменение конкретного элемента, а не окна.
14. Типизированные события и кастомные события
14.1. Генерация и обработка CustomEvent с типами
// events.ts
type AppEventMap = {
'user:login': { userId: string; timestamp: Date };
'theme:change': { theme: 'light' | 'dark' };
'modal:open': { id: string; data?: unknown };
};
// Расширяем глобальный EventTarget
declare global {
interface GlobalEventHandlersEventMap extends AppEventMap {}
}
// Утилиты
export function dispatchAppEvent<E extends keyof AppEventMap>(
type: E,
detail: AppEventMap[E]
): boolean {
const event = new CustomEvent(type, { detail });
return window.dispatchEvent(event);
}
export function addAppEventListener<E extends keyof AppEventMap>(
type: E,
listener: (ev: CustomEvent<AppEventMap[E]>) => void,
options?: boolean | AddEventListenerOptions
): () => void {
const handler = (ev: Event) => listener(ev as CustomEvent<AppEventMap[E]>);
window.addEventListener(type, handler, options);
return () => window.removeEventListener(type, handler, options);
}
// Использование:
dispatchAppEvent('user:login', { userId: 'u123', timestamp: new Date() });
// В любом компоненте:
useEffect(() => {
return addAppEventListener('user:login', ev => {
console.log('User logged in:', ev.detail.userId);
analytics.track('login', ev.detail);
});
}, []);
🔗 Поддержка:
- автодополнения
type;- тип
detailвыводится изtype;- совместимость с
useEventListener(если есть).
15. State-менеджмент без библиотек
15.1. Типизированный глобальный store (паттерн observable store)
type Listener<T> = (state: T) => void;
class Store<T> {
private state: T;
private listeners = new Set<Listener<T>>();
constructor(initialState: T) {
this.state = initialState;
}
getState(): T {
return this.state;
}
setState(partial: Partial<T> | ((prevState: T) => Partial<T>)): void {
const update = typeof partial === 'function' ? partial(this.state) : partial;
this.state = { ...this.state, ...update };
this.listeners.forEach(fn => fn(this.state));
}
subscribe(listener: Listener<T>): () => void {
this.listeners.add(listener);
listener(this.state); // сразу вызываем с текущим состоянием
return () => this.listeners.delete(listener);
}
// React-обёртка
useStore(): T {
const [state, setState] = useState(this.state);
useEffect(() => this.subscribe(setState), []);
return state;
}
}
// Пример: глобальный UI-стейт
interface UIState {
darkMode: boolean;
sidebarOpen: boolean;
notifications: { id: string; message: string }[];
}
const uiStore = new Store<UIState>({
darkMode: false,
sidebarOpen: true,
notifications: []
});
// В компоненте:
function ThemeToggle() {
const { darkMode } = uiStore.useStore();
const toggle = () => uiStore.setState({ darkMode: !darkMode });
useEffect(() => {
document.documentElement.classList.toggle('dark', darkMode);
}, [darkMode]);
return <button onClick={toggle}>{darkMode ? '☀️' : '🌙'}</button>;
}
// Где-то ещё:
uiStore.setState(prev => ({
notifications: [...prev.notifications, { id: Date.now().toString(), message: 'Сохранено!' }]
}));
🧱 Просто, предсказуемо, без
context-древовидности. Подходит для небольших и средних приложений.
16. Утилиты для форм
16.1. useForm с валидацией и типизацией
type FieldValidator<T> = (value: T, allValues: any) => string | null;
interface FieldConfig<T> {
initialValue: T;
validate?: FieldValidator<T>;
}
interface FormConfig<T extends Record<string, any>> {
[K in keyof T]: FieldConfig<T[K]>;
}
export function useForm<T extends Record<string, any>>(
config: FormConfig<T>
) {
type FormState = {
values: T;
errors: { [K in keyof T]?: string };
touched: { [K in keyof T]: boolean };
isValid: boolean;
};
const [state, setState] = useState<FormState>(() => {
const values = {} as T;
const errors = {} as { [K in keyof T]?: string };
const touched = {} as { [K in keyof T]: boolean };
for (const key in config) {
values[key] = config[key].initialValue;
touched[key] = false;
}
return { values, errors, touched, isValid: true };
});
const updateField = <K extends keyof T>(name: K, value: T[K]) => {
setState(prev => {
const newValues = { ...prev.values, [name]: value };
const newTouched = { ...prev.touched, [name]: true };
// Валидация
const fieldConfig = config[name];
const error = fieldConfig.validate
? fieldConfig.validate(value, newValues)
: null;
const newErrors = { ...prev.errors, [name]: error };
const newIsValid = Object.values(newErrors).every(err => err == null);
return { values: newValues, errors: newErrors, touched: newTouched, isValid: newIsValid };
});
};
const handleSubmit = (onSubmit: (values: T) => void | Promise<void>) => (e: React.FormEvent) => {
e.preventDefault();
// Финальная валидация всех полей
const errors = {} as { [K in keyof T]?: string };
let isValid = true;
for (const key in config) {
const field = config[key];
const value = state.values[key];
const error = field.validate?.(value, state.values) ?? null;
if (error) {
errors[key] = error;
isValid = false;
}
}
if (isValid) {
onSubmit(state.values);
} else {
setState(prev => ({ ...prev, errors, isValid: false }));
}
};
return {
...state,
setFieldValue: updateField,
handleSubmit
};
}
// Использование:
const { values, errors, touched, isValid, setFieldValue, handleSubmit } = useForm({
email: {
initialValue: '',
validate: (v) => !v ? 'Обязательно' : !/\S+@\S+\.\S+/.test(v) ? 'Неверный email' : null
},
password: {
initialValue: '',
validate: (v) => v.length < 8 ? 'Минимум 8 символов' : null
}
});
return (
<form onSubmit={handleSubmit(data => api.login(data))}>
<Input
value={values.email}
onChange={e => setFieldValue('email', e.target.value)}
error={touched.email ? errors.email : undefined}
/>
<Button type="submit" disabled={!isValid}>Войти</Button>
</form>
);
✅ Поддержка:
- асинхронных валидаторов (можно расширить через
Promise<string | null>);- cross-field валидации (
allValues);touchedдля UX.
17. Типизированный WebSocket-клиент с reconnect и heartbeat
type MessageMap = {
'auth:success': { token: string; expiresIn: number };
'auth:error': { code: string; message: string };
'data:update': { id: string; payload: unknown };
'ping': { timestamp: number };
'pong': { timestamp: number };
};
type OutgoingMessage =
| { type: 'auth'; credentials: { login: string; password: string } }
| { type: 'subscribe'; topic: string }
| { type: 'ping'; timestamp: number };
class TypedWebSocket {
private ws: WebSocket | null = null;
private url: string;
private reconnectDelay = 1000;
private maxReconnectDelay = 30_000;
private backoff = 1;
private heartbeatInterval: number | null = null;
private lastPong = Date.now();
private readonly listeners = new Map<keyof MessageMap, Set<(payload: any) => void>>();
constructor(url: string) {
this.url = url;
}
private connect(): void {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.resetBackoff();
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as { type: keyof MessageMap; data?: unknown };
const handlers = this.listeners.get(msg.type);
if (handlers) {
handlers.forEach(fn => fn(msg.data));
}
if (msg.type === 'pong') this.lastPong = Date.now();
} catch (err) {
console.error('WebSocket message parse error', err);
}
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
this.ws.onerror = (err) => {
console.error('WebSocket error', err);
};
}
private startHeartbeat(): void {
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
this.heartbeatInterval = window.setInterval(() => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
// Проверка отклика
if (Date.now() - this.lastPong > 10_000) {
this.ws.close(1000, 'No pong received');
return;
}
this.send({ type: 'ping', timestamp: Date.now() });
}, 5000);
}
private resetBackoff(): void {
this.backoff = 1;
}
private scheduleReconnect(): void {
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
const delay = Math.min(this.reconnectDelay * this.backoff, this.maxReconnectDelay);
this.backoff = Math.min(this.backoff * 2, 8);
setTimeout(() => this.connect(), delay);
}
on<E extends keyof MessageMap>(type: E, listener: (data: MessageMap[E]) => void): () => void {
const set = this.listeners.get(type) ?? new Set();
set.add(listener as (data: any) => void);
this.listeners.set(type, set);
if (!this.ws) this.connect();
return () => {
set.delete(listener as (data: any) => void);
if (set.size === 0) this.listeners.delete(type);
};
}
send(msg: OutgoingMessage): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}
this.ws.send(JSON.stringify(msg));
}
close(): void {
this.ws?.close();
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
}
}
// Использование:
const ws = new TypedWebSocket('wss://api.example.com/ws');
const unsub1 = ws.on('auth:success', ({ token }) => {
localStorage.setItem('wsToken', token);
});
const unsub2 = ws.on('data:update', ({ id, payload }) => {
updateLocalCache(id, payload);
});
ws.send({ type: 'auth', credentials: { login: 'user', password: 'pass' } });
// Unmount:
useEffect(() => () => { unsub1(); unsub2(); ws.close(); }, []);
✅ Поддержка:
- экспоненциального бэк-оффа;
- heartbeat/timeout для обнаружения «зависших» соединений;
- типобезопасных подписок;
- автоматического reconnect.
18. Web Workers с типизированными сообщениями
18.1. Обёртка для worker’а (main thread)
// worker/types.ts
type WorkerRequest =
| { type: 'init'; config: { apiKey: string } }
| { type: 'process'; data: number[] }
| { type: 'terminate' };
type WorkerResponse =
| { type: 'ready' }
| { type: 'result'; result: number }
| { type: 'error'; message: string };
// worker/index.ts (главный поток)
class TypedWorker {
private worker: Worker;
private idCounter = 0;
private pending = new Map<number, (res: WorkerResponse) => void>();
constructor() {
this.worker = new Worker(new URL('./worker.impl.ts', import.meta.url), { type: 'module' });
this.worker.onmessage = (ev) => {
const { id, payload } = ev.data as { id: number; payload: WorkerResponse };
this.pending.get(id)?.(payload);
this.pending.delete(id);
};
}
private postMessage<R extends WorkerResponse>(req: WorkerRequest): Promise<R> {
const id = ++this.idCounter;
return new Promise((resolve, reject) => {
this.pending.set(id, (res) => {
if (res.type === 'error') reject(new Error(res.message));
else resolve(res as R);
});
this.worker.postMessage({ id, payload: req });
});
}
async init(config: { apiKey: string }): Promise<void> {
await this.postMessage({ type: 'init', config });
}
async process(data: number[]): Promise<number> {
const res = await this.postMessage<{ type: 'result'; result: number }>({
type: 'process', data
});
return res.result;
}
terminate(): void {
this.worker.postMessage({ id: 0, payload: { type: 'terminate' } });
this.worker.terminate();
}
}
// worker.impl.ts (worker)
import type { WorkerRequest, WorkerResponse } from './types';
let apiKey = '';
self.onmessage = async (ev) => {
const { id, payload } = ev.data as { id: number; payload: WorkerRequest };
const send = (res: WorkerResponse) => {
self.postMessage({ id, payload: res });
};
try {
switch (payload.type) {
case 'init':
apiKey = payload.config.apiKey;
send({ type: 'ready' });
break;
case 'process':
const sum = payload.data.reduce((a, b) => a + b, 0);
// Имитация тяжёлых вычислений
await new Promise(r => setTimeout(r, 100));
send({ type: 'result', result: sum });
break;
case 'terminate':
self.close();
break;
default:
send({ type: 'error', message: `Unknown request: ${(payload as any).type}` });
}
} catch (err) {
send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
}
};
🧵 Преимущества:
- изоляция тяжёлых вычислений от UI-потока;
- типизированный IPC;
import.meta.url— SSR-safe (new Worker(...)не сработает в Node, но в Deno/Bun — да);- отмена через
terminate.
19. AST-анализ и трансформация TypeScript без компилятора
19.1. Парсинг ts → AST → модификация → генерация js
import * as ts from 'typescript';
function transformTypeScript(source: string, fileName = 'input.ts'): string {
// 1. Парсинг
const sourceFile = ts.createSourceFile(
fileName,
source,
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TS
);
// 2. Визитор для замены console.log → logger.info
const transformer: ts.TransformerFactory<ts.SourceFile> = context => {
const visit: ts.Visitor = node => {
if (ts.isCallExpression(node)) {
const expr = node.expression;
if (
ts.isPropertyAccessExpression(expr) &&
ts.isIdentifier(expr.expression) &&
expr.expression.text === 'console' &&
expr.name.text === 'log'
) {
// Заменяем console.log(...) → logger.info(...)
return ts.factory.createCallExpression(
ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier('logger'),
'info'
),
undefined,
node.arguments
);
}
}
return ts.visitEachChild(node, visit, context);
};
return node => ts.visitNode(node, visit);
};
// 3. Трансформация
const result = ts.transform(sourceFile, [transformer]);
const transformedSource = result.transformed[0];
// 4. Генерация JS (без типов)
const printer = ts.createPrinter({ removeComments: false });
const jsCode = printer.printFile(transformedSource);
result.dispose();
return jsCode;
}
// Пример:
const tsCode = `
function greet(name: string): void {
console.log("Hello,", name);
}
`;
const jsCode = transformTypeScript(tsCode);
// → function greet(name) { logger.info("Hello,", name); }
// Для production: использовать @swc/core или esbuild — но для плагинов/анализа AST — ts API незаменим.
🔍 Подходит для:
- кастомных линтеров;
- codemod’ов;
- генерации кода (например, из декораторов);
- анализа зависимостей на уровне AST.
20. Генерация .d.ts вручную (для legacy API)
20.1. Динамическая декларация на основе JSON-схемы
interface Schema {
name: string;
methods: {
name: string;
params: { name: string; type: string }[];
returns: string;
}[];
}
function generateDTS(schema: Schema): string {
const methodDecls = schema.methods.map(m => {
const params = m.params.map(p => `${p.name}: ${mapType(p.type)}`).join(', ');
const returnType = mapType(m.returns);
return `${m.name}(${params}): ${returnType};`;
}).join('\n ');
return `declare namespace ${schema.name} {
${methodDecls}
}
declare const ${schema.name}: ${schema.name};
`;
}
function mapType(tsType: string): string {
return ({
'str': 'string',
'int': 'number',
'bool': 'boolean',
'obj': 'Record<string, any>',
'arr': 'any[]',
'any': 'any'
} as Record<string, string>)[tsType] ?? tsType;
}
// Пример: BPMSoft-подобный API
const legacySchema: Schema = {
name: 'BPMScript',
methods: [
{ name: 'getValue', params: [{ name: 'key', type: 'str' }], returns: 'any' },
{ name: 'setValue', params: [{ name: 'key', type: 'str' }, { name: 'value', type: 'any' }], returns: 'void' },
{ name: 'runProcess', params: [{ name: 'processId', type: 'str' }], returns: 'bool' }
]
};
const dts = generateDTS(legacySchema);
// →
// declare namespace BPMScript {
// getValue(key: string): any;
// setValue(key: string, value: any): void;
// runProcess(processId: string): boolean;
// }
// declare const BPMScript: BPMScript;
// Можно записать в файл:
// await fs.promises.writeFile('bpm.d.ts', dts);
📜 Такой подход позволяет:
- поддерживать legacy-код без ручного написания
d.ts;- генерировать декларации из OpenAPI/Swagger/MetaDB;
- интегрировать в CI (генерация при сборке).
21. Интеграция с федеральными системами (ГИС, ЕПГУ, СМЭВ)
21.1. Безопасная отправка XML + подпись (ГОСТ Р 34.10-2012)
// Упрощённый пример (реально — через node-gost или криптопровайдер ОС)
interface SmevRequest {
messageId: string;
timestamp: string;
data: Record<string, unknown>;
}
async function signSmevRequest(req: SmevRequest, certPath: string, keyPath: string): Promise<string> {
const xml = buildSmevXml(req);
const signature = await gostSign(xml, certPath, keyPath);
return injectSignature(xml, signature);
}
function buildSmevXml(req: SmevRequest): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">
<S:Header>
<ns2:MessageID>${req.messageId}</ns2:MessageID>
<ns2:Timestamp>${req.timestamp}</ns2:Timestamp>
</S:Header>
<S:Body>
<ns2:Data>${JSON.stringify(req.data)}</ns2:Data>
</S:Body>
</S:Envelope>`;
}
// Мок подпись (реально — вызов native-модуля)
async function gostSign(data: string, cert: string, key: string): Promise<string> {
// В production: использовать node-webcrypto-ossl, @trust/webcrypto, или вызов через child_process + КриптоПро
return `SIGNATURE_${Buffer.from(data).toString('base64').slice(0, 16)}`;
}
function injectSignature(xml: string, sig: string): string {
return xml.replace('</S:Header>', `</S:Header>\n<S:Signature>${sig}</S:Signature>`);
}
// Использование:
const req = {
messageId: 'MSG-' + Date.now(),
timestamp: new Date().toISOString(),
data: { citizenId: '12345678901' }
};
const signedXml = await signSmevRequest(req, '/certs/smev.cer', '/keys/smev.key');
await fetch('https://smev.gov.ru/service', {
method: 'POST',
headers: { 'Content-Type': 'text/xml' },
body: signedXml
});
🛡️ Важно:
- для ГОСТ — обязательна сертифицированная криптография (КриптоПро, ViPNet и т.п.);
- в Node.js — использовать нативные аддоны или CLI-обёртки;
- в браузере — Web Crypto API не поддерживает ГОСТ → нужен промежуточный сервис.
22. Декораторы без experimentalDecorators
⚠️ Начиная с TypeScript 5.0, новые декораторы — стандарт ECMA, не требуют флага
experimentalDecorators. Ниже — реализация на их основе.
22.1. Декораторы: @Validate, @Cache, @Inject
// Метаданные — без reflect-metadata
const METADATA = Symbol('decorators');
interface ValidatorFn {
(value: unknown, propertyKey: string): string | null;
}
interface ValidatorSpec {
[property: string]: ValidatorFn[];
}
function getValidators(target: any): ValidatorSpec {
return target[METADATA]?.validators ?? (target[METADATA] = { validators: {} }).validators;
}
// Декоратор свойства
function Validate(validator: ValidatorFn) {
return (value: any, context: ClassFieldDecoratorContext) => {
if (context.kind !== 'field') throw new Error('@Validate применим только к полям');
const { name } = context;
const validators = getValidators(context.metadata);
(validators[name as string] ??= []).push(validator);
return value;
};
}
// Декоратор метода (валидация перед вызовом)
function ValidateMethod(target: any, context: ClassMethodDecoratorContext) {
if (context.kind !== 'method') throw new Error('@ValidateMethod применим только к методам');
const originalMethod = context.descriptor.value!;
context.descriptor.value = function (...args: any[]) {
const validators = getValidators(target.constructor);
const paramNames = Reflect.getMetadata('design:paramtypes', target, context.name as string) ?? [];
// Для простоты — предполагаем, что args[0] — объект с полями (DTO)
const dto = args[0];
if (dto && typeof dto === 'object') {
for (const [key, rules] of Object.entries(validators)) {
if (key in dto) {
for (const rule of rules) {
const err = rule(dto[key], key);
if (err) throw new Error(`Validation failed for ${String(context.name)}.${key}: ${err}`);
}
}
}
}
return originalMethod.apply(this, args);
};
return context.descriptor;
}
// Пример использования:
class CreateUserDto {
@Validate(v => typeof v === 'string' && v.length >= 3 ? null : 'Минимум 3 символа')
name!: string;
@Validate(v => typeof v === 'string' && /\S+@\S+\.\S+/.test(v) ? null : 'Неверный email')
email!: string;
}
class UserService {
@ValidateMethod
async create(dto: CreateUserDto): Promise<string> {
// гарантировано: dto прошёл валидацию
return await db.users.insert(dto);
}
}
// Запуск:
const service = new UserService();
await service.create({ name: 'Ti', email: 'bad' }); // → Error: Validation failed...
✅ Преимущества:
- совместимость с
tsc --target es2022+;- ноль runtime-зависимостей;
- типобезопасность (можно расширить через generics);
- не требует
reflect-metadata.
22.2. Декоратор кэширования (@Memoize)
function Memoize<T extends (...args: any[]) => any>(
options: { ttlMs?: number; keyFn?: (...args: Parameters<T>) => string } = {}
) {
const { ttlMs = 30_000, keyFn = JSON.stringify } = options;
return (value: T, context: ClassMethodDecoratorContext) => {
if (context.kind !== 'method') throw new Error('@Memoize применим только к методам');
const cache = new Map<string, { result: ReturnType<T>; expiresAt: number }>();
return function (this: any, ...args: Parameters<T>): ReturnType<T> {
const key = keyFn(...args);
const now = Date.now();
const cached = cache.get(key);
if (cached && cached.expiresAt > now) {
return cached.result;
}
const result = value.apply(this, args);
cache.set(key, { result, expiresAt: now + ttlMs });
return result;
} as T;
};
}
// Использование:
class DataFetcher {
private client = new ApiClient({ baseUrl: 'https://...' });
@Memoize({ ttlMs: 60_000, keyFn: (id: string) => `user:${id}` })
async getUser(id: string): Promise<User> {
return this.client.get(`/users/${id}`);
}
@Memoize({ ttlMs: 5_000, keyFn: () => 'stats' })
async getStats(): Promise<Stats> {
return this.client.get('/stats');
}
}
🗃️ Поддерживает:
- TTL;
- кастомные ключи (чтобы избежать
JSON.stringifyдля объектов);- экземпляр-локальный кэш (не глобальный).
23. Типизированный SQL-билдер без ORM
Цель: получить compile-time безопасность (как
kysely), но без зависимостей и runtime-накладных.
23.1. Минималистичный билдер с inference’ом
type Column<T> = { _type: T };
type Table<T extends Record<string, Column<any>>> = { _schema: T };
// Примитивная типизация колонок
function col<T>(type: T): Column<T> { return { _type: type }; }
// Пример таблицы
const users = {
id: col<number>(),
name: col<string>(),
email: col<string>(),
createdAt: col<Date>()
} satisfies Table<Record<string, Column<any>>>;
type InferTable<T> = T extends Table<infer S> ? { [K in keyof S]: S[K] extends Column<infer U> ? U : never } : never;
// Билдер
class SelectBuilder<T extends Record<string, Column<any>>, R = InferTable<T>> {
private _select: (keyof T)[] = [];
private _where: string[] = [];
private _limit?: number;
from(_table: T): this { return this; }
select<K extends keyof T>(...cols: K[]): SelectBuilder<T, Pick<InferTable<T>, K>> {
this._select.push(...cols);
return this as any;
}
where(condition: string): this {
this._where.push(condition);
return this;
}
limit(n: number): this {
this._limit = n;
return this;
}
toSql(): string {
const selectClause = this._select.length ? this._select.join(', ') : '*';
const whereClause = this._where.length ? ` WHERE ${this._where.join(' AND ')}` : '';
const limitClause = this._limit ? ` LIMIT ${this._limit}` : '';
return `SELECT ${selectClause} FROM users${whereClause}${limitClause};`;
}
// Имитация выполнения (в реальности — через pg/mysql2)
async execute(): Promise<R[]> {
console.log('[SQL]', this.toSql());
return [] as R[]; // мок
}
}
// Использование:
const db = new SelectBuilder().from(users);
const q1 = db
.select('id', 'name')
.where('email = $1')
.limit(10);
type Result1 = Awaited<ReturnType<typeof q1.execute>>; // { id: number; name: string }[]
const usersData = await q1.execute();
// const bad = q1.select('emailx'); // ❌ TS2345: 'emailx' does not exist in 'users'
🔍 Возможности расширения:
- JOIN с inference’ом связей;
- безопасные параметры (
where('email = ?', [email])→ escape);- миграции через те же декларации.
24. Микросервисы: типизированная шина событий
24.1. Kafka/RabbitMQ-подобная шина с zod-схемами
import { z } from 'zod';
const EventSchemas = {
'user.created': z.object({
userId: z.string().uuid(),
email: z.string().email(),
timestamp: z.number()
}),
'order.paid': z.object({
orderId: z.string(),
amount: z.number().positive(),
currency: z.enum(['RUB', 'USD', 'EUR'])
})
} as const;
type EventMap = {
[K in keyof typeof EventSchemas]: z.infer<(typeof EventSchemas)[K]>;
};
interface OutboxEvent<E extends keyof EventMap = keyof EventMap> {
type: E;
data: EventMap[E];
traceId: string;
timestamp: number;
}
class EventBus {
private publishers = new Map<string, (ev: OutboxEvent) => Promise<void>>();
registerPublisher(topic: string, publisher: (ev: OutboxEvent) => Promise<void>) {
this.publishers.set(topic, publisher);
}
async publish<E extends keyof EventMap>(
type: E,
data: EventMap[E],
meta: { traceId: string }
): Promise<void> {
const schema = EventSchemas[type];
const parseResult = schema.safeParse(data);
if (!parseResult.success) {
throw new Error(`Validation failed for event ${String(type)}: ${parseResult.error.message}`);
}
const event: OutboxEvent<E> = {
type,
data: parseResult.data,
traceId: meta.traceId,
timestamp: Date.now()
};
const publisher = this.publishers.get('main');
if (!publisher) throw new Error('No publisher registered for "main"');
await publisher(event);
}
}
// Пример publisher’а для Kafka (мок)
const kafkaPublisher = async (ev: OutboxEvent) => {
const key = ev.type === 'user.created' ? ev.data.userId : ev.data.orderId;
await kafka.send({
topic: 'events',
messages: [{ key, value: JSON.stringify(ev) }]
});
};
const bus = new EventBus();
bus.registerPublisher('main', kafkaPublisher);
// Гарантированно типобезопасно:
await bus.publish('user.created', {
userId: '550e8400-e29b-41d4-a716-446655440000',
email: 'timur@example.com',
timestamp: Date.now()
}, { traceId: 'trace-123' });
📦 Ключевые аспекты:
- валидация на отправке (не только в consumer’е);
traceIdдля distributed tracing;- совместимость с OpenTelemetry.
25. CLI-утилита «с нуля»: subcommands, help, автодополнение
25.1. Парсер process.argv без зависимостей
type ArgSpec = {
type: 'string' | 'number' | 'boolean';
description?: string;
default?: any;
required?: boolean;
};
type CommandSpec = {
name: string;
description: string;
args?: Record<string, ArgSpec>;
options?: Record<string, ArgSpec>;
handler: (args: Record<string, any>, opts: Record<string, any>) => Promise<void>;
};
class Cli {
private commands = new Map<string, CommandSpec>();
private globalOptions: Record<string, ArgSpec> = {};
command(spec: CommandSpec): this {
this.commands.set(spec.name, spec);
return this;
}
option(name: string, spec: ArgSpec): this {
this.globalOptions[name] = spec;
return this;
}
async run(argv = process.argv.slice(2)): Promise<void> {
if (argv.length === 0 || ['--help', '-h'].includes(argv[0])) {
return this.printHelp();
}
const [cmdName, ...rest] = argv;
const cmd = this.commands.get(cmdName);
if (!cmd) {
console.error(`Unknown command: ${cmdName}`);
process.exit(1);
}
const { args: parsedArgs, options: parsedOpts } = this.parseArgs(rest, cmd.args, cmd.options);
await cmd.handler(parsedArgs, { ...parsedOpts, ...this.parseGlobalOptions(argv) });
}
private parseArgs(
tokens: string[],
argSpecs?: Record<string, ArgSpec>,
optSpecs?: Record<string, ArgSpec>
): { args: Record<string, any>; options: Record<string, any> } {
const args: Record<string, any> = {};
const options: Record<string, any> = {};
let argIndex = 0;
let i = 0;
while (i < tokens.length) {
const token = tokens[i];
if (token.startsWith('--')) {
const name = token.slice(2);
const spec = optSpecs?.[name] ?? this.globalOptions[name];
if (!spec) throw new Error(`Unknown option: --${name}`);
if (spec.type === 'boolean') {
options[name] = true;
i++;
} else {
const value = tokens[++i];
options[name] = this.coerce(value, spec.type);
i++;
}
} else if (argSpecs) {
const argNames = Object.keys(argSpecs);
if (argIndex < argNames.length) {
const name = argNames[argIndex++];
args[name] = this.coerce(token, argSpecs[name].type);
i++;
} else {
throw new Error(`Too many positional arguments`);
}
} else {
i++; // игнорируем (или ошибка)
}
}
// Заполняем defaults
for (const [name, spec] of Object.entries(argSpecs || {})) {
if (args[name] === undefined && 'default' in spec) args[name] = spec.default;
if (spec.required && args[name] === undefined) throw new Error(`Missing required arg: ${name}`);
}
for (const [name, spec] of Object.entries(optSpecs || {})) {
if (options[name] === undefined && 'default' in spec) options[name] = spec.default;
}
return { args, options };
}
private parseGlobalOptions(argv: string[]): Record<string, any> {
const opts: Record<string, any> = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--verbose' || argv[i] === '-v') opts.verbose = true;
}
return opts;
}
private coerce(value: string, type: string): any {
switch (type) {
case 'number': return Number(value);
case 'boolean': return value === 'true';
default: return value;
}
}
private printHelp(): void {
console.log('Usage: app <command> [args] [options]');
console.log();
console.log('Commands:');
for (const cmd of this.commands.values()) {
console.log(` ${cmd.name.padEnd(15)} ${cmd.description}`);
}
console.log();
console.log('Global options:');
console.log(' -v, --verbose Enable verbose logging');
console.log(' -h, --help Show help');
}
// Генерация автодополнения для bash
generateBashCompletion(): string {
const cmds = [...this.commands.keys()].join(' ');
return `#!/bin/bash
_app() {
local cur prev opts
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
opts="${cmds}"
if [[ \${COMP_CWORD} -eq 1 ]]; then
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
return 0
fi
}
complete -F _app app
`;
}
}
// Использование:
const cli = new Cli();
cli
.command({
name: 'migrate',
description: 'Run DB migrations',
options: {
'dry-run': { type: 'boolean', description: 'Simulate only' },
'to': { type: 'number', description: 'Target version', default: -1 }
},
handler: async (args, opts) => {
console.log('Migrating to', opts.to, opts['dry-run'] ? '(dry-run)' : '');
// ...
}
})
.command({
name: 'seed',
description: 'Populate DB with demo data',
args: {
count: { type: 'number', description: 'Number of records', default: 100 }
},
handler: async ({ count }) => {
console.log(`Seeding ${count} records...`);
}
})
.option('verbose', { type: 'boolean', description: 'Verbose output' });
// Запуск:
// $ ts-node cli.ts migrate --to 5 --dry-run
cli.run();
🖥️ Поддержка:
- позиционных аргументов;
- опций с типами и defaults;
- help-страницы;
- генерации
bash/zshавтодополнения.
26. Транспиляция TS → JS в памяти (для edge/serverless)
26.1. transpileModule без файловой системы
import * as ts from 'typescript';
interface TranspileResult {
js: string;
sourceMap?: string;
diagnostics: ts.Diagnostic[];
}
function transpileTsInMemory(
code: string,
options: { fileName?: string; target?: ts.ScriptTarget } = {}
): TranspileResult {
const { fileName = 'input.ts', target = ts.ScriptTarget.ES2022 } = options;
const compilerOptions: ts.CompilerOptions = {
target,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
strict: true,
esModuleInterop: true,
skipLibCheck: true,
sourceMap: true,
inlineSources: true
};
const result = ts.transpileModule(code, {
compilerOptions,
fileName,
reportDiagnostics: true
});
const diagnostics = result.diagnostics?.filter(d => d.category === ts.DiagnosticCategory.Error) ?? [];
return {
js: result.outputText,
sourceMap: result.sourceMapText,
diagnostics
};
}
// Пример: serverless function
export async function handler(event: any) {
const { code } = event.body;
const { js, diagnostics } = transpileTsInMemory(code);
if (diagnostics.length > 0) {
const errors = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'));
return { statusCode: 400, body: JSON.stringify({ errors }) };
}
// Выполняем в изоляте (vm, Function — осторожно!)
const fn = new Function('require', 'module', 'exports', js);
const module = { exports: {} };
try {
fn(undefined, module, module.exports);
return { statusCode: 200, body: JSON.stringify(module.exports) };
} catch (err) {
return { statusCode: 500, body: JSON.stringify({ runtimeError: String(err) }) };
}
}
⚠️ Важно:
new Functionнебезопасен для untrusted code → использоватьvmв Node,WebContainerв браузере;- для production — кэшировать результаты транспиляции по хэшу кода.