Перейти к основному содержимому

Дженерики в TypeScript

Разработчику Архитектору

Дальше: Паттерны · Объекты и классы · Справочник — утилитарные типы


Дженерики (generics) — параметры типа: одна функция или класс работает с разными данными, а компилятор сохраняет связь между аргументом и результатом. Без них пришлось бы дублировать код или опираться на any.

Маршрут: функциидженерикиasync Promise<T>.

Аналогия в C# — обобщения. Указатель в JS-курсе — JS-курс: дженерики.


Связанный выбор: один аргумент сужает другой

В выразительном TypeScript часто работает одна схема: первый выбор (ключ, имя события, исходная единица, статус ответа) задаёт второй (тип значения, payload, целевая единица, доступные поля).

Первый выборВторой сужается доГде разобрано
ключ Kтип T[K]ниже §keyof, updater
имя события EPayloadMap[E]паттерны — event bus
единица fromтолько совместимые to§Compat
status в unionполя веткиnarrowing, Exclude

На уровне значений сужение делает if / switch (12.md); на уровне сигнатур — ограничения T extends … и вспомогательные типы вроде Compat<F>.


Обобщённые типы (generics)

Без genericsС generics
function first(arr: any[])function first<T>(arr: T[]): T | undefined
Потеря типа элементаIDE знает точный тип результата
Дублирование firstString, firstNumberОдна реализация
function first<T>(items: T[]): T | undefined {
return items[0];
}

const n = first([1, 2, 3]); // number | undefined
const s = first(["a", "b"]); // string | undefined

Разбор:

  • Tпараметр типа, подставляется при вызове.
  • Компилятор выводит T из аргумента, явно писать <number> не обязательно.

Синтаксис: функции, типы, классы

Функция

function identity<T>(value: T): T {
return value;
}

const a = identity<string>("text");
const b = identity(42); // T = number

Тип и interface

type Box<T> = { value: T };

interface Repository<T extends { id: string }> {
findById(id: string): T | undefined;
save(entity: T): void;
}

Класс

class Container<T> {
constructor(private value: T) {}

getValue(): T {
return this.value;
}

setValue(value: T): void {
this.value = value;
}
}

const c = new Container("hello"); // Container<string>

Разбор:

  • Поле value имеет тип T на всём жизненном цикле экземпляра.
  • Методы наследуют тот же T.

Соглашения об именах

ИмяОбычно означает
TПроизвольный тип
KKey (ключ)
VValue (значение)
EElement
TResultТип результата

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


Ограничения (extends)

Параметр типа можно ограничить минимальным контрактом:

interface HasId {
id: string;
}

function getId<T extends HasId>(entity: T): string {
return entity.id;
}
interface Lengthwise {
length: number;
}

function logLength<T extends Lengthwise>(value: T): void {
console.log(value.length);
}

logLength("abc"); // OK
logLength([1, 2, 3]); // OK
// logLength(42); // ошибка: у number нет length

Разбор:

  • T extends HasId означает: тип T обязан иметь поле id: string.
  • Внутри функции доступны только поля контракта HasId, если не сузить дальше.

Несколько параметров

function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}

keyof и индексированный доступ

type User = { id: string; name: string; role: "admin" | "user" };

type UserKey = keyof User; // "id" | "name" | "role"
type UserName = User["name"]; // string

function getField<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const user: User = { id: "1", name: "Анна", role: "user" };
const name = getField(user, "name"); // string

Разбор:

  • K extends keyof T — ключ должен существовать в T.
  • Возврат T[K] — точный тип поля, не any.

Связанные generic: выбор по группе (Compat)

Иногда второй generic-параметр должен зависеть от первого не по ключу объекта, а по общей группе (размерность, категория, домен). Пример — конвертер единиц: из km можно перевести только в длину, не в массу.

type Dimension = "length" | "mass";

type UnitDef = { base: Dimension; factor: number };

const REGISTRY = {
m: { base: "length", factor: 1 },
km: { base: "length", factor: 1000 },
mi: { base: "length", factor: 1609.34 },
g: { base: "mass", factor: 1 },
kg: { base: "mass", factor: 1000 },
lb: { base: "mass", factor: 453.592 },
} as const satisfies Record<string, UnitDef>;

type UnitKey = keyof typeof REGISTRY;

type Compat<F extends UnitKey> = {
[P in UnitKey]: (typeof REGISTRY)[P]["base"] extends (typeof REGISTRY)[F]["base"]
? P
: never;
}[UnitKey];

declare function convert<F extends UnitKey, T extends Compat<F>>(
value: number,
from: F,
to: T,
): number;

// convert(10, "km", "mi"); // OK — обе length
// convert(10, "km", "kg"); // ошибка: kg не в Compat<"km">

Разбор:

  • as const сохраняет литеральные ключи и поля; satisfies Record<string, UnitDef> проверяет форму без расширения типов — 10.md §as const.
  • Compat<F>mapped type + условный тип: для каждого ключа P оставляем P, если base совпадает с base у F, иначе never; индексация [UnitKey] собирает union совместимых единиц.
  • T extends Compat<F>связанный (dependent) generic: после выбора from автодополнение для to показывает только ту же группу.

Проверка на уровне типов (без runtime):

type MiOk = "mi" extends Compat<"km"> ? true : false; // true
type KgBad = "kg" extends Compat<"km"> ? true : false; // false

Тот же приём — роли в одном домене, валюты, типы узлов в AST: общее поле-группа + Compat-подобный тип.


Паттерн: type-safe updater

Связка Omit, keyof и generic — из практики крупных кодовых баз:

type Visibility = "draft" | "active" | "archived";

interface Product {
readonly id: string;
name: string;
price: number;
visibility: Visibility;
}

type Updatable<T> = Omit<T, "id">;

type SafeUpdate<T> = <K extends keyof Updatable<T>>(
key: K,
value: Updatable<T>[K],
) => T;

const createUpdater =
<T,>(entity: T): SafeUpdate<T> =>
(key, value) => ({ ...entity, [key]: value });

const product: Product = {
id: "p1",
name: "Стол",
price: 899,
visibility: "draft",
};

const update = createUpdater(product);
update("price", 699); // OK
update("visibility", "active"); // OK
// update("id", "x"); // ошибка: id исключён
// update("price", "free"); // ошибка: нужен number

Смысл ограничения: нельзя обновить запрещённое поле и нельзя подставить значение неверного типа.

Подробнее — Справочник — комбо-паттерн обновления.


Generic-класс: репозиторий

type Entity = { id: string };

class InMemoryRepository<T extends Entity> {
private items: T[] = [];

add(item: T): void {
this.items.push(item);
}

findById(id: string): T | undefined {
return this.items.find((i) => i.id === id);
}

list(): readonly T[] {
return this.items;
}
}

type User = Entity & { name: string };

const users = new InMemoryRepository<User>();
users.add({ id: "1", name: "Анна" });

Дженерики в стандартной библиотеке

APIПример
Array<T>string[]
Promise<T>Promise<User>Асинхронность
Map<K, V>Map<string, number>
Record<K, V>Record<Role, string[]>
type User = { id: string; name: string };

async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw: unknown = await res.json();
if (!isUser(raw)) throw new Error("Invalid user JSON");
return raw;
}

function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const o = value as Record<string, unknown>;
return typeof o.id === "string" && typeof o.name === "string";
}

Разбор:

  • Promise<User> — тип всей async-функции; await res.json() даёт unknown, не User.
  • value is User — type predicate: после if (!isUser(raw)) в ветке return raw тип уже User.
  • as Promise<User> на json() — частая ошибка: json() возвращает Promise<unknown>, а не типизированный ответ API.

Значения по умолчанию для параметра типа

type ApiResponse<TData = unknown> = {
ok: boolean;
data: TData;
};

type UserResponse = ApiResponse<User>;
type EmptyResponse = ApiResponse; // data: unknown

Обязательные generic-параметры идут перед параметрами с default.


Условные типы и infer

Продвинутый уровень — выбор типа по условию:

type IsString<T> = T extends string ? true : false;

type ReturnTypeOf<T> = T extends (...args: never[]) => infer R ? R : never;

type Fn = () => string;
type R = ReturnTypeOf<Fn>; // string

Разбор:

  • infer R извлекает тип возвращаемого значения из сигнатуры функции.
  • Встроенный ReturnType<T> делает то же — функции §утилиты.

Mapped types ({ [K in keyof T]: … }) — ниже и в справочнике §8.


Сопоставленные типы и обобщённая индексация

Сопоставленный тип (mapped type) строит новый объектный тип по шаблону для каждого ключа. Обобщённая индексация — запись PayloadMap[E]: по имени события E компилятор подставляет точную форму payload.

Форма payload привязана к имени события. Обратились к полю из другого события внутри обработчика — получите ошибку компиляции, а не сюрприз в runtime.

const WebhookEvent = {
PaymentSucceeded: "payment.succeeded",
PaymentFailed: "payment.failed",
RefundCreated: "refund.created",
} as const;

type WebhookEventType = (typeof WebhookEvent)[keyof typeof WebhookEvent];
// "payment.succeeded" | "payment.failed" | "refund.created"

type PayloadMap = {
[WebhookEvent.PaymentSucceeded]: { chargeId: string; amount: number };
[WebhookEvent.PaymentFailed]: { chargeId: string; reason: string };
[WebhookEvent.RefundCreated]: { refundId: string; amount: number };
};

type WebhookHandler<E extends WebhookEventType> = (
payload: PayloadMap[E],
) => void;

const onPaymentSucceeded: WebhookHandler<"payment.succeeded"> = (payload) => {
console.log(payload.chargeId, payload.amount);
};

const onPaymentFailed: WebhookHandler<"payment.failed"> = (payload) => {
console.log(payload.chargeId, payload.reason);
};

const onRefundCreated: WebhookHandler<"refund.created"> = (payload) => {
// console.log(payload.chargeId);
// ошибка: Property 'chargeId' does not exist on RefundCreated payload
console.log(payload.refundId, payload.amount);
};

Разбор:

  • as const на WebhookEvent сохраняет литеральные строки; WebhookEventType выводится без ручного union — 10.md §as const.
  • PayloadMap — mapped type: ключ = имя события, значение = контракт payload.
  • WebhookHandler<E> + PayloadMap[E]generic indexing: параметр типа E выбирает ветку карты.
  • Тот же приём в event bus (Events[K]) и в связанном выборе.

Отличие от класса TypedEmitter: здесь тип описывает отдельные функции-обработчики; шина добавляет регистрацию и emit в runtime.


Пути во вложенном объекте: Path и PathType

Для конфигов, форм и state иногда нужны строковые пути ("user.preferences.theme") с точным типом значения на конце. Два вспомогательных типа строят union путей и извлекают тип по пути.

type Path<T> = T extends object
? {
[K in keyof T]: K extends string ? `${K}` | `${K}.${Path<T[K]>}` : never;
}[keyof T]
: never;

type PathType<T, P extends Path<T>> = P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? Rest extends Path<T[Key]>
? PathType<T[Key], Rest>
: never
: never
: P extends keyof T
? T[P]
: never;

interface AppState {
user: {
id: string;
preferences: {
theme: "light" | "dark";
lang: "en" | "es";
};
};
ui: { sidebarOpen: boolean };
}

type AppStatePath = Path<AppState>;
// "user" | "ui" | "user.id" | "user.preferences" | "user.preferences.theme" | …

type Theme = PathType<AppState, "user.preferences.theme">; // "light" | "dark"
type Sidebar = PathType<AppState, "ui.sidebarOpen">; // boolean

Разбор:

  • Path<T>рекурсия + template literal types (`${K}.${…}`): обход вложенных объектов на уровне типов.
  • PathTypeinfer Key / infer Rest: разбор строки пути по первой точке и рекурсивный спуск.
  • В продакшене часто берут готовые библиотеки; здесь — как устроена «чистая» версия для учёбы.

Ограничения упрощённой версии: optional-поля, массивы и union внутри объекта усложняют Path; для учебного примера достаточно вложенных обязательных полей, как выше.


Собственный Pick

type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};

type UserPreview = MyPick<User, "id" | "name">;

В проектах используйте встроенный Pick / Omitтипы §utility.


Частые ошибки

ОшибкаЧто делать
Слишком общий T без extendsДобавить минимальный контракт
Generic без реальных кейсовУпростить до конкретного типа
<T,> с запятойВ .tsx запятая отличает generic от JSX
Путать T[] и Array<T>Эквивалентны; выберите стиль команды
as any внутри generic-утилитыСохраняет дыры в типах — сузить через extends

Практика

  1. Реализуйте MyPick<T, K> и MyOmit<T, K> и сравните с встроенными.
  2. Напишите function pluck<T, K extends keyof T>(items: T[], key: K): T[K][].
  3. Сделайте InMemoryRepository<T extends Entity> с методом update(id, patch: Partial<T>).
  4. Реализуйте три WebhookHandler для payment.succeeded, payment.failed, refund.created и поймайте ошибку на chargeId в refund — §сопоставленные типы.
  5. Добавьте в реестр единиц cm и проверьте, что convert не принимает to из другой размерности.
  6. Для AppState выведите PathType<AppState, "user.preferences.lang"> и убедитесь, что неверный путь даёт ошибку компиляции.
  7. Замените две похожие функции разного типа одной generic.

Смежные статьи