Дженерики TypeScript: constraints и паттерны
Дженерики — одна из самых мощных и одновременно самых сложных концепций TypeScript. Они позволяют писать код, который работает с любыми типами, сохраняя при этом полную типобезопасность.
Дженерик — это «переменная для типа». Как обычная функция принимает значение и возвращает результат, так дженерик принимает тип и возвращает типизированный результат. В React дженерики используются в хуках, компонентах, utility-типах. Во Vue — в composables, пропсах, хелперах. В этой статье разберём дженерики от базовых концепций до продвинутых паттернов, применяемых в реальных проектах.
Содержание
- Что такое дженерики и зачем они нужны
- Как дженерики работают под капотом
- Базовый синтаксис
- Дженерики в функциях
- Дженерики в интерфейсах и типах
- Дженерики в классах
- Constraints — ограничения типов
- Default types — типы по умолчанию
- Дженерики в React
- Дженерики во Vue
- Продвинутые паттерны
- Как читать сложные дженерики
- Типичные ошибки
Что такое дженерики и зачем они нужны
Проблема, которую решают дженерики
Представьте, что вам нужна функция, которая возвращает переданное значение. Без дженериков у вас два плохих варианта:
// Вариант 1: теряем тип — возвращается any
function identity(value: any): any {
return value;
}
const result = identity("hello"); // result: any — тип потерян
// TypeScript не знает, что result — это string
// Можно написать result.toFixed() — и получить ошибку в рантайме
// Вариант 2: пишем отдельную функцию для каждого типа
function identityString(value: string): string {
return value;
}
function identityNumber(value: number): number {
return value;
}
// Бесконечное дублирование кода
Оба варианта плохи: первый теряет типобезопасность, второй требует дублирования кода.
Решение — дженерики
Дженерики позволяют передать тип как параметр. Функция identity принимает не только значение, но и тип этого значения:
function identity<T>(value: T): T {
return value;
}
const a = identity("hello"); // a: string — TypeScript вывел тип
const b = identity(42); // b: number
const c = identity(true); // c: boolean
Аналогия: Дженерик — это «переменная для типа». Как обычная функция принимает значение и возвращает результат, так дженерик принимает тип и возвращает типизированный результат. Только вместо
valueвы передаётеT.
Ключевая идея
Дженерики создают шаблон, из которого TypeScript создаёт конкретные типы при каждом вызове. Когда вы пишете identity<string>("hello"), TypeScript подставляет string вместо T и получает:
// Что видит TypeScript после подстановки:
function identity(value: string): string {
return value;
}
Это называется мономорфизацией — из одного шаблона создаётся множество конкретных функций.
Где дженерики встречаются в повседневном коде
| Контекст | Пример | Что происходит |
|---|---|---|
| React хуки | useState<User>(initialUser) | Хук хранит состояние типа User |
| Vue composables | useLocalStorage<User>('key', defaultUser) | Composable работает с типом User |
| Fetch / API | fetch<User>('/api/user') | Ответ сервера типизирован как User |
| Коллекции | Array<string>, Map<string, User> | Массив строк, словарь с ключами-строками и значениями-User |
| Utility-типы | Partial<User>, Pick<User, 'name'> | Трансформация типа User |
Как дженерики работают под капотом
Чтобы по-настоящему понять дженерики, нужно разобраться, что происходит на этапе компиляции.
Механизм подстановки типов
Когда вы объявляете дженерик, вы создаёте шаблон. TypeScript не создаёт реальный код — он запоминает правила. При каждом использовании дженерика TypeScript подставляет конкретные типы и проверяет, что всё корректно.
// Объявление шаблона
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// Использование — подстановка типов
const a = first([1, 2, 3]); // T = number → возвращает number | undefined
const b = first(["a", "b"]); // T = string → возвращает string | undefined
const c = first<boolean>([]); // T = boolean (явно) → возвращает boolean | undefined
Type inference — автоматический вывод типов
TypeScript сам выводит тип дженерика из контекста. Вам не нужно писать <string> каждый раз:
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair("age", 25);
// TypeScript видит:
// - первый аргумент "age" → A = string
// - второй аргумент 25 → B = number
// - результат: [string, number]
Type inference работает, когда тип можно однозначно определить из аргументов. Если нельзя — нужно указывать явно:
function empty<T>(): T[] {
return [];
}
// TypeScript не может вывести T из контекста
const arr1 = empty(); // Error: T is unknown
const arr2 = empty<string>(); // OK: T = string
Проверка типов на этапе компиляции
Важно понимать: дженерики существуют только на этапе компиляции. В рантайме никакого T не существует — это называется type erasure (стирание типов). JavaScript не знает о типах.
function checkType<T>(value: T): void {
// В рантайме T не существует
// Нельзя написать: if (value instanceof T)
// Можно только работать со значением как с T
}
Базовый синтаксис
Дженерик объявляется угловыми скобками <> с именем типа. Традиционно используются заглавные латинские буквы.
Один параметр типа
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstStr = first(["a", "b", "c"]); // string | undefined
const firstNum = first([1, 2, 3]); // number | undefined
Что происходит:
first<string>(["a", "b", "c"])— TypeScript подставляетstringвместоT- Сигнатура становится:
function first(arr: string[]): string | undefined - Возвращаемый тип —
string | undefined, потому что массив может быть пустым
Несколько параметров типа
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p = pair("age", 25); // [string, number]
const q = pair(1, true); // [number, boolean]
Что происходит:
AиB— независимые параметры типа- TypeScript выводит их из аргументов:
A = string,B = number - Результат — кортеж
[string, number]
Именование типов
Принятые соглашения:
T— Type (один тип, самый частый случай)K— Key (ключ)V— Value (значение)A, B— когда нужно несколько разных типовTData, TError— описательные имена для сложных случаев (префиксTпоказывает, что это тип)
// Хороший стиль для API-хука — описательные имена
function useFetch<TData, TError = Error>(
url: string
): { data: TData | null; error: TError | null } {
// ...
}
Совет: Используйте
Tдля простых случаев (один-два параметра). Для трёх и более — дайте описательные имена:TData,TError,TResult.
Дженерики в функциях
Функция с несколькими дженериками
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const lengths = map(["hello", "world"], s => s.length); // number[]
const upper = map(["hello", "world"], s => s.toUpperCase()); // string[]
Разбор по шагам:
map(["hello", "world"], s => s.length)- TypeScript видит:
arr— массив строк →T = string - Функция
s => s.lengthпринимаетstringи возвращаетnumber→U = number - Результат:
number[]
Второй вызов:
map(["hello", "world"], s => s.toUpperCase())T = string(массив строк)s.toUpperCase()возвращаетstring→U = string- Результат:
string[]
Функция с constraint (ограничением)
Без ограничения TypeScript не знает, какие свойства есть у T. Если нужно обратиться к свойству, используйте extends:
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
getLength("hello"); // OK — string имеет length
getLength([1, 2, 3]); // OK — array имеет length
getLength(42); // Error — number не имеет length
Что происходит:
T extends { length: number }означает: «T может быть любым типом, у которого есть свойствоlengthтипаnumber»- TypeScript проверяет, что аргумент соответствует ограничению
42(number) не имеетlength→ ошибка компиляции
Подробнее про constraints — в отдельной секции.
Дженерики в интерфейсах и типах
Дженерики можно использовать не только в функциях, но и в типах. Это позволяет создавать переиспользуемые шаблоны типов.
Generic interface
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
}
// Подставляем User вместо T
const response: ApiResponse<User> = {
data: { id: 1, name: "John" },
status: 200,
message: "OK"
};
// Подставляем Product вместо T
interface Product {
id: number;
title: string;
price: number;
}
const productResponse: ApiResponse<Product> = {
data: { id: 1, title: "Phone", price: 500 },
status: 200,
message: "OK"
};
Что происходит:
ApiResponse<T>— это шаблон. Сам по себе он не является типом.ApiResponse<User>— конкретный тип, гдеT = User- TypeScript подставляет
Userво все места, где используетсяT - Получается:
{ data: User; status: number; message: string }
Generic type alias
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseJSON(json: string): Result<unknown> {
try {
return { ok: true, value: JSON.parse(json) };
} catch (e) {
return { ok: false, error: e as Error };
}
}
// Использование
const result = parseJSON('{"name": "John"}');
if (result.ok) {
console.log(result.value); // unknown
} else {
console.log(result.error); // Error
}
Что происходит:
Result<T, E>— union-тип с двумя вариантамиE = Error— тип по умолчанию (подробнее — в секции про default types)Result<unknown>подставляетT = unknown,E = Error(по умолчанию)- TypeScript проверяет
result.okи сужает тип: еслиok: true, тоvalue: unknown; еслиok: false, тоerror: Error
Интерфейс с несколькими типами
interface Dictionary<K extends string | number, V> {
get(key: K): V | undefined;
set(key: K, value: V): void;
keys(): K[];
}
type UserDict = Dictionary<string, { name: string; age: number }>;
// Эквивалентно:
// {
// get(key: string): { name: string; age: number } | undefined;
// set(key: string, value: { name: string; age: number }): void;
// keys(): string[];
// }
Что происходит:
K extends string | number— ключ может быть только строкой или числомV— значение любого типа- При использовании
Dictionary<string, User>подставляются оба параметра
Дженерики в классах
Дженерики в классах работают так же, как в интерфейсах — они параметризуют тип класса.
Базовый generic-класс
class Container<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getFirst(): T | undefined {
return this.items[0];
}
getAll(): T[] {
return [...this.items];
}
}
const numContainer = new Container<number>();
numContainer.add(1);
numContainer.add(2);
const first = numContainer.getFirst(); // number | undefined
// numContainer.add("hello"); // Error — ожидается number
const strContainer = new Container<string>();
strContainer.add("hello");
// strContainer.add(42); // Error — ожидается string
Что происходит:
Container<T>— шаблон классаnew Container<number>()создаёт экземпляр, гдеT = number- Все методы используют
numberвместоT:add(item: number),getFirst(): number | undefined - Каждый экземпляр имеет свой тип —
Container<number>иContainer<string>несовместимы
Generic-класс с constraint
interface Identifiable {
id: string;
}
class Repository<T extends Identifiable> {
private items: Map<string, T> = new Map();
add(item: T): void {
this.items.set(item.id, item);
}
getById(id: string): T | undefined {
return this.items.get(id);
}
}
interface User extends Identifiable {
id: string;
name: string;
}
const userRepo = new Repository<User>();
userRepo.add({ id: "1", name: "John" });
const user = userRepo.getById("1"); // User | undefined
Что происходит:
T extends Identifiable— класс работает только с типами, у которых естьid: stringRepository<User>—Userдолжен иметьid: string(удовлетворяет ограничению)- Метод
getByIdвозвращаетT | undefined, то естьUser | undefined
Constraints — ограничения типов
Constraints позволяют ограничить, какие типы можно передать в дженерик. Без ограничений TypeScript считает, что T может быть чем угодно, и вы не можете обращаться к его свойствам.
Зачем нужны constraints
Без ограничения TypeScript не знает, какие свойства есть у T:
// Error — T может не иметь свойства length
function getLength<T>(item: T): number {
return item.length; // TypeScript не знает, есть ли length у T
}
С ограничением TypeScript знает, что T имеет нужные свойства:
// OK — T обязательно имеет length
function getLength<T extends { length: number }>(item: T): number {
return item.length;
}
extends — базовое ограничение
extends означает «должен быть совместим с». Тип T может быть любым, но обязан иметь указанные свойства.
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
interface User { id: number; name: string; }
interface Product { id: number; title: string; price: number; }
const users: User[] = [{ id: 1, name: "John" }];
const user = findById(users, 1); // User | undefined
const products: Product[] = [{ id: 1, title: "Phone", price: 500 }];
const product = findById(products, 1); // Product | undefined
Что происходит:
T extends HasId—Tдолжен иметьid: numberUserиProductоба имеютid: number→ оба подходят- TypeScript сохраняет конкретный тип:
findById(users, 1)возвращаетUser | undefined, а неHasId | undefined
keyof — ограничение ключами объекта
keyof T — это union всех ключей типа T. Ограничение K extends keyof T означает «K должен быть одним из ключей T».
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "John", age: 25 };
const name = getProperty(user, "name"); // string
const age = getProperty(user, "age"); // number
const wrong = getProperty(user, "email"); // Error — "email" не ключ user
Разбор по шагам:
getProperty(user, "name")T = { name: string; age: number }(выведен изuser)keyof T = "name" | "age"(все ключи объекта)K extends "name" | "age"—Kдолжен быть"name"или"age""name"подходит →K = "name"- Возвращаемый тип:
T[K] = T["name"] = string
Для "email":
"email"не входит в"name" | "age"→ ошибка компиляции
Multiple constraints
Можно ограничивать несколько параметров типа одновременно:
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
const merged = merge({ name: "John" }, { age: 25 });
// merged: { name: string } & { age: number }
// TypeScript вывел: T = { name: string }, U = { age: number }
Default types — типы по умолчанию
Типы по умолчанию позволяют не указывать параметр типа, если вас устраивает стандартное значение.
interface Pagination<T, TOrder extends "asc" | "desc" = "asc"> {
items: T[];
total: number;
order: TOrder;
}
// TOrder по умолчанию "asc" — можно не указывать
const page1: Pagination<User> = {
items: [],
total: 0,
order: "asc"
};
// Явно указываем "desc"
const page2: Pagination<User, "desc"> = {
items: [],
total: 0,
order: "desc"
};
Что происходит:
TOrder extends "asc" | "desc" = "asc"—TOrderможет быть только"asc"или"desc", по умолчанию"asc"Pagination<User>—T = User,TOrder = "asc"(по умолчанию)Pagination<User, "desc">—T = User,TOrder = "desc"(явно)
Типы по умолчанию работают справа налево: если у вас Pagination<T, TOrder = "asc">, то T указывать обязательно, а TOrder — нет.
Дженерики в React
React активно использует дженерики в хуках и компонентах. Понимание механизма помогает правильно типизировать код.
useState с дженериком
interface User {
id: number;
name: string;
email: string;
}
function UserProfile() {
// Явно указываем тип — важно для nullable значений
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
// user: User | null — можно присвоить User или null
// users: User[] — массив пользователей
}
Почему важно указывать тип явно:
- Без
<User | null>TypeScript выведетnullиз начального значения - Тогда
setUser({ id: 1, name: "John", email: "..." })выдаст ошибку - С
<User | null>тип корректный: можно присвоить иUser, иnull
useReducer с дженериком
interface State {
count: number;
error: string | null;
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "setError"; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1, error: null };
case "decrement":
return { ...state, count: state.count - 1, error: null };
case "setError":
return { ...state, error: action.payload };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
// state: State
// dispatch: (action: Action) => void
}
Что происходит:
useReducerвыводит тип состояния из начального значения и reducerdispatchпринимает толькоAction— другие объекты не пройдут- TypeScript проверяет
action.typeи сужает тип внутриswitch
Generic-компоненты
Generic-компоненты позволяют создавать переиспользуемые UI-элементы с сохранением типов.
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Использование — тип выводится автоматически из items
<List
items={[{ id: 1, name: "John" }, { id: 2, name: "Jane" }]}
renderItem={user => <span>{user.name}</span>}
keyExtractor={user => String(user.id)}
/>
Что происходит:
List<T>— компонент принимает параметр типа- TypeScript выводит
Tизitems: массив объектов сidиname→T = { id: number; name: string } renderItemиkeyExtractorполучают типизированныйitem- Внутри
renderItemTypeScript знает, чтоuserимеетnameиid
Generic forwardRef
forwardRef с дженериками требует явного приведения типов, потому что forwardRef не поддерживает дженерики напрямую.
interface InputProps<T> {
value: T;
onChange: (value: T) => void;
format?: (value: T) => string;
}
function InputInner<T>(
{ value, onChange, format }: InputProps<T>,
ref: React.Ref<HTMLInputElement>
) {
const display = format ? format(value) : String(value);
return <input ref={ref} value={display} onChange={e => onChange(value)} />;
}
// Приведение типов — forwardRef не поддерживает дженерики
const Input = React.forwardRef(InputInner) as <T>(
props: InputProps<T> & React.RefAttributes<HTMLInputElement>
) => React.ReactElement;
Дженерики во Vue
Vue 3 с Composition API активно использует дженерики в composables и типизированных компонентах.
defineProps с дженериком
<script setup lang="ts" generic="T">
interface Props<T> {
items: T[];
selected: T | null;
onSelect: (item: T) => void;
}
const props = defineProps<Props<T>>();
</script>
<template>
<ul>
<li v-for="item in items" @click="onSelect(item)">
{{ item }}
</li>
</ul>
</template>
Что происходит:
generic="T"— объявляет параметр типа для компонента (аналог<T>в React)Props<T>— пропсы параметризованы типомT- При использовании компонента TypeScript выводит
Tизitems
Composable с дженериком
import { ref, type Ref } from "vue";
function useLocalStorage<T>(key: string, initialValue: T) {
const storedValue = ref<T>(initialValue) as Ref<T>;
try {
const item = localStorage.getItem(key);
if (item) {
storedValue.value = JSON.parse(item) as T;
}
} catch {
storedValue.value = initialValue;
}
function setValue(value: T) {
storedValue.value = value;
localStorage.setItem(key, JSON.stringify(value));
}
return { value: storedValue, setValue };
}
// Использование
const { value: user, setValue: setUser } = useLocalStorage<User>("user", {
id: 0,
name: ""
});
// user: Ref<User>
// setUser: (value: User) => void
Что происходит:
useLocalStorage<T>— composable параметризован типомTuseLocalStorage<User>("user", {...})—T = Userref<T>(initialValue)создаёт реактивное значение типаUsersetValueпринимает толькоUser
Generic defineEmits
<script setup lang="ts" generic="T extends { id: number }">
const props = defineProps<{
items: T[];
}>();
const emit = defineEmits<{
select: [item: T];
remove: [id: number];
}>();
</script>
Что происходит:
T extends { id: number }— компонент работает только с типами, у которых естьid: numberemit("select", item)—itemтипизирован какTemit("remove", id)—idтипизирован какnumber
Смежные темы: Встроенные utility-типы (
Partial,Pick,Omit,Recordи др.) подробно разобраны в статье TypeScript Utility Types. Условные типы иinfer— в статье TypeScriptinfer.
Продвинутые паттерны
Фабрика с дженериком
function createApiService<T extends { id: string }>(baseUrl: string) {
return {
async getAll(): Promise<T[]> {
const res = await fetch(baseUrl);
return res.json();
},
async getById(id: string): Promise<T | null> {
const res = await fetch(`${baseUrl}/${id}`);
return res.json();
},
async create(item: Omit<T, "id">): Promise<T> {
const res = await fetch(baseUrl, {
method: "POST",
body: JSON.stringify(item)
});
return res.json();
}
};
}
interface User { id: string; name: string; }
const userService = createApiService<User>("/api/users");
// userService.getAll(): Promise<User[]>
// userService.getById("1"): Promise<User | null>
// userService.create({ name: "John" }): Promise<User>
Что происходит:
createApiService<User>создаёт сервис, типизированный подUserT extends { id: string }— сервис работает только с типами, у которых естьid: stringOmit<T, "id">— при создании убираемid(сервер сгенерирует его)- Все методы возвращают типизированные результаты
Типизированный event emitter
type EventMap = Record<string, any[]>;
class TypedEmitter<T extends EventMap> {
private listeners: { [K in keyof T]?: Array<(...args: T[K]) => void> } = {};
on<K extends keyof T>(event: K, listener: (...args: T[K]) => void) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, ...args: T[K]) {
this.listeners[event]?.forEach(fn => fn(...args));
}
}
interface AppEvents {
login: [user: User];
logout: [];
error: [message: string, code: number];
}
const emitter = new TypedEmitter<AppEvents>();
emitter.on("login", user => console.log(user.name)); // OK
emitter.on("error", (msg, code) => console.log(msg, code)); // OK
Что происходит:
EventMap— карта событий: ключ — имя события, значение — массив аргументовTypedEmitter<AppEvents>— эмиттер, типизированный подAppEventson("login", ...)— TypeScript знает, что listener принимаетUseron("error", ...)— TypeScript знает, что listener принимаетstringиnumber- Попытка
on("unknown", ...)— ошибка компиляции
Как читать сложные дженерики
Алгоритм разбора:
- Найдите параметры типа — что внутри
<>? - Найдите constraints — что после
extends? - Найдите, где используется
T— в аргументах, возвращаемом типе, свойствах? - Подставьте конкретный тип — замените
Tна реальный тип и посмотрите, что получится
function createApiService<T extends { id: string }>(baseUrl: string) {
// ...
}
const userService = createApiService<User>("/api/users");
// 1. Параметры: T
// 2. Constraint: T extends { id: string }
// 3. T используется в возвращаемых методах
// 4. Подстановка: T = User → все методы типизированы под User
Типичные ошибки
1. Избыточные дженерики
// Плохо — дженерик не нужен, тип и так выводится
function getValue<T>(value: T): T {
return value;
}
// Хорошо — дженерик связывает вход и выход
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
2. Неправильное использование в React
// Плохо — тип не передаётся в useState
const [user, setUser] = useState(null);
// user: null — нельзя присвоить User
// Хорошо — явно указываем тип
const [user, setUser] = useState<User | null>(null);
// user: User | null
Ключевые тезисы для интервью
- Дженерики — «переменные для типов»: позволяют писать переиспользуемый код без потери типобезопасности.
- TypeScript выводит тип дженерика из контекста (type inference), но иногда нужно указывать явно.
- Дженерики существуют только на этапе компиляции — в рантайме происходит type erasure.
extendsограничивает, какие типы можно передать в дженерик:T extends HasIdтребует наличиеid.keyof Tвозвращает union всех ключей;T[K]— тип значения для ключаK.- Типы по умолчанию работают справа налево: обязательные параметры идут первыми.
- В React дженерики используются в
useState<T>,useReducer, generic-компонентах,forwardRef. - Во Vue дженерики применяются в
defineProps<T>,defineEmits, composables сgeneric="T". - Описательные имена (
TData,TError) улучшают читаемость при трёх и более параметрах типа. - Встроенные utility-типы (
Partial,Pick,Omit,Record) — это дженерики, трансформирующие типы.
Заключение
Дженерики — фундамент типобезопасности в TypeScript. Они позволяют:
- Писать переиспользуемый код без потери типов
- Создавать гибкие API с проверкой на этапе компиляции
- Строить сложные utility-типы для трансформации данных
- Типизировать компоненты и хуки в React/Vue
Правила для запоминания:
- Используйте дженерики, когда нужно связать типы входных и выходных данных
- Добавляйте constraints (
extends), когда нужны гарантии о структуре типа - Давайте описательные имена (
TData,TError), если дженериков больше одного - Пользуйтесь встроенными utility-типами вместо написания своих
- Разбирайте сложные дженерики по шагам: параметры → constraints → использование → подстановка