Дженерики в TypeScript
Дальше: Паттерны · Объекты и классы · Справочник — утилитарные типы
Дженерики (generics) — параметры типа: одна функция или класс работает с разными данными, а компилятор сохраняет связь между аргументом и результатом. Без них пришлось бы дублировать код или опираться на any.
Маршрут: функции → дженерики → async Promise<T>.
Аналогия в C# — обобщения. Указатель в JS-курсе — JS-курс: дженерики.
Связанный выбор: один аргумент сужает другой
В выразительном TypeScript часто работает одна схема: первый выбор (ключ, имя события, исходная единица, статус ответа) задаёт второй (тип значения, payload, целевая единица, доступные поля).
| Первый выбор | Второй сужается до | Где разобрано |
|---|---|---|
ключ K | тип T[K] | ниже §keyof, updater |
имя события E | PayloadMap[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 | Произвольный тип |
K | Key (ключ) |
V | Value (значение) |
E | Element |
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}.${…}`): обход вложенных объектов на уровне типов.PathType—infer 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 |
Практика
- Реализуйте
MyPick<T, K>иMyOmit<T, K>и сравните с встроенными. - Напишите
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][]. - Сделайте
InMemoryRepository<T extends Entity>с методомupdate(id, patch: Partial<T>). - Реализуйте три
WebhookHandlerдляpayment.succeeded,payment.failed,refund.createdи поймайте ошибку наchargeIdв refund — §сопоставленные типы. - Добавьте в реестр единиц
cmи проверьте, чтоconvertне принимаетtoиз другой размерности. - Для
AppStateвыведитеPathType<AppState, "user.preferences.lang">и убедитесь, что неверный путь даёт ошибку компиляции. - Замените две похожие функции разного типа одной generic.
Смежные статьи
- Функции —
filter<T>, типы callback - Типы — union, utility types
- Коллекции —
Map,Set,Record - Паттерны — discriminated union + generics
- Справочник · Справочник — утилитарные и расширенные типы