Ограничения generics (extends)
Как ограничивать типы с помощью extends и keyof
Цели урока
- Понять, зачем нужны ограничения generics
- Освоить синтаксис
<T extends Type> - Разобрать оператор
keyof - Научиться комбинировать extends и keyof
Проблема: слишком свободный тип
Представьте, что вы написали обобщённую функцию, которая возвращает длину массива:
function getLength<T>(value: T): number {
// Ошибка! T может быть чем угодно,
// у него может не быть свойства length
return value.length; // Ошибка компиляции!
}
// Это работает:
getLength("hello"); // OK — string имеет length
getLength([1, 2, 3]); // OK — массив имеет length
// Но это тоже «работает» — и это плохо:
getLength(42); // number не имеет length!
getLength({ name: "test" }); // Объект без length!
TypeScript не может знать, что T должен иметь свойство length. Нам нужно ограничить тип.
Решение: extends
Ключевое слово extends позволяет ограничить тип T, чтобы он соответствовал определённой структуре:
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
// Теперь TypeScript проверяет:
getLength("hello"); // OK! string имеет length
getLength([1, 2, 3]); // OK! массив имеет length
getLength({ length: 10 }); // OK! объект имеет length
// Ошибка: number не имеет length!
// getLength(42); // Ошибка компиляции!
// Ошибка: объект без length!
// getLength({ name: "test" }); // Ошибка компиляции!
Синтаксис: <T extends SomeType> означает: «T может быть любым типом, который расширяет (наследует структуру) SomeType».
extends для интерфейсов
Вы можете ограничить тип конкретным интерфейсом:
interface HasId {
id: number;
}
// T должен иметь id
function findById<T extends HasId>(
items: T[],
id: number
): T | undefined {
return items.find(item => item.id === id);
}
// Пользователь соответствует HasId
interface User extends HasId {
name: string;
email: string;
}
// OK!
const users: User[] = [{ id: 1, name: "Алиса", email: "a@b.com" }];
findById(users, 1); // OK!
// Ошибка: строка не имеет id
// findById(["hello", "world"], 1); // Ошибка!
Оператор keyof
keyof — это оператор TypeScript, который возвращает объединение тип всех ключей объекта:
interface User {
name: string;
age: number;
email: string;
}
// keyof User = "name" | "age" | "email"
type UserKeys = keyof User;
// UserKeys — это "name" | "age" | "email"
// Использование:
function getValue<T, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}
// TypeScript знает тип возвращаемого значения!
const user: User = { name: "Алиса", age: 25, email: "a@b.com" };
getValue(user, "name"); // string — TypeScript знает тип!
getValue(user, "age"); // number
// getValue(user, "phone"); // Ошибка: "phone" нет в типе User
Как это работает:
keyof T— возвращаетобъединение тип всех ключейTK extends keyof T—Kдолжен быть одним из ключейTT[K]— indexed access type: тип значения по ключуKв типеT
Практический пример: безопасное обновление объекта
Функция, которая безопасно обновляет свойство объекта:
function updateProperty<
T extends Record<string, any>,
K extends keyof T
>(
obj: T,
key: K,
value: T[K]
): T {
return { ...obj, [key]: value };
}
// Использование:
interface User {
name: string;
age: number;
email: string;
}
const user: User = { name: "Алиса", age: 25, email: "a@b.com" };
// OK! Типы совпадают:
updateProperty(user, "name", "Борис"); // OK!
updateProperty(user, "age", 30); // OK!
// Ошибка: передаём строку вместо числа для age:
// updateProperty(user, "age", "тридцать"); // Ошибка!
// Ошибка: ключа "phone" нет в типе User:
// updateProperty(user, "phone", "123"); // Ошибка!
extends с несколькими ограничениями
Вы можете ограничить тип несколькими условиями, используя пересечение типов:
function greet<T extends { id: number } & { name: string }>(
entity: T
): string {
return `Привет, ${entity.name} (ID: ${entity.id})`;
}
// OK!
greet({ id: 1, name: "Алиса" }); // OK!
greet({ id: 1, name: "Борис", age: 25 }); // OK!
// Ошибка: нет name:
// greet({ id: 1 }); // Ошибка!
// Ошибка: нет id:
// greet({ name: "Алиса" }); // Ошибка!
Практический пример: глубокое получение значения
Функция для безопасного получения вложенных значений:
function getNestedValue<
T extends Record<string, any>,
K1 extends keyof T,
K2 extends keyof T[K1]
>(
obj: T,
key1: K1,
key2: K2
): T[K1][K2] {
return obj[key1][key2];
}
// Использование:
const config = {
database: { host: "localhost", port: 5432 },
server: { port: 3000, host: "0.0.0.0" }
};
getNestedValue(config, "database", "port"); // number (5432)
getNestedValue(config, "database", "host"); // string ("localhost")
// getNestedValue(config, "database", "name"); // Ошибка!
extends для классов
Вы можете ограничить тип конкретным классом:
class Animal {
constructor(public name: string) {}
}
// T должен быть наследником Animal
function getName<T extends Animal>(animal: T): string {
return animal.name;
}
// Наследник Animal:
class Dog extends Animal {
bark() { console.log("Гав!"); }
}
// OK!
getName(new Dog("Рекс")); // "Рекс"
getName(new Animal("Рыбка")); // "Рыбка"
// Ошибка: строка не наследник Animal:
// getName("Рекс"); // Ошибка!
Практический пример: типобезопасный emit
Создадим типобезопасную систему событий, где ключи событий связаны с типами данных:
interface EventMap {
login: { userId: number };
logout: { userId: number };
message: { text: string; from: string };
}
// Типобезопасный emit
function emit<
K extends keyof EventMap
>(
event: K,
data: EventMap[K]
): void {
console.log(`Event: ${event}`, data);
}
// OK! Типы данных совпадают:
emit("login", { userId: 1 }); // OK!
emit("message", { text: "Привет", from: "Алиса" }); // OK!
// Ошибка: неверные данные для login:
// emit("login", { text: "Привет" }); // Ошибка!
// Ошибка: несуществующее событие:
// emit("click", {}); // Ошибка!
extends для создания условий
Вы можете использовать extends для создания условных типов:
type StringToNumber<T> = T extends string ? number : T;
// Результат:
// StringToNumber<string> = number
// StringToNumber<number> = number
// StringToNumber<boolean> = boolean
// Использование в функции:
function convert<T>(value: T): StringToNumber<T> {
if (typeof value === "string") {
return Number(value) as StringToNumber<T>;
}
return value as StringToNumber<T>;
}
convert("42"); // number
convert(42); // number
Советы по использованию extends
- Ограничения типов по структуре
- Создания типобезопасных утилит
- Валидации ключей объектов
- Условных типов
- Слишком сложных ограничений
- Избыточных ограничений, когда они не нужны
- Использования extends вместо any
- Забывания про keyof
Итоги урока
extendsограничивает типT, чтобы он соответствовал определённой структуреkeyof Tвозвращаетобъединение тип всех ключей объектаTT[K]— indexed access type: тип значения по ключу- Можно ограничивать несколькими условиями через пересечение типов
- extends работает с интерфейсами, классами и литеральными типами
- extends позволяет создавать типобезопасные утилиты
- Используйте extends для создания условных типов
Практический пример: типобезопасный map
Функция map, которая безопасно преобразует элементы массива:
function safeMap<T, U>(
arr: T[],
fn: (item: T, index: number) => U
): U[] {
const result: U[] = [];
for (let i = 0; i < arr.length; i++) {
result.push(fn(arr[i], i));
}
return result;
}
// TypeScript знает типы на каждом этапе:
const numbers = [1, 2, 3, 4, 5];
const doubled = safeMap(numbers, (n, i) => n * 2);
// doubled: number[] — [2, 4, 6, 8, 10]
// Преобразование типов:
const names = ["Алиса", "Борис", "Виктор"];
const lengths = safeMap(names, (name) => name.length);
// lengths: number[] — [5, 5, 6]
Практический пример: типобезопасный reduce
Функция reduce, которая аккумулирует значения с сохранением типов:
function safeReduce<T, U>(
arr: T[],
fn: (acc: U, item: T, index: number) => U,
initial: U
): U {
let acc = initial;
for (let i = 0; i < arr.length; i++) {
acc = fn(acc, arr[i], i);
}
return acc;
}
// Подсчёт суммы:
const sum = safeReduce(
[1, 2, 3, 4],
(acc, num) => acc + num,
0
); // 10 (number)
// Группировка:
const grouped = safeReduce(
["apple", "banana", "avocado"],
(acc, fruit) => {
const letter = fruit[0];
return { ...acc, [letter]: [...(acc[letter] || []), fruit] };
},
{} as Record<string, string[]>
); // { a: ["apple", "avocado"], b: ["banana"] }
Практический пример: типобезопасный debounce с контекстом
Улучшенная версия debounce, которая сохраняет контекст вызова:
function debounceWithContext<
T extends (...args: any[]) => any
>(
fn: T,
delay: number,
context?: any
): T {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: any[]) => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(context, args);
}, delay);
}) as T;
}
// Использование:
class SearchService {
private search = debounceWithContext(
(query: string) => {
console.log(`Поиск: ${query}`);
},
300,
this
);
}
Распространённые ошибки с extends
Вот частые ошибки, которые делают новички при использовании extends:
function getValue<T, K>(obj: T, key: K): T[K] {
return obj[key]; // K не ограничен!
}
// Правильно:
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// function complex<T extends { a: { b: { c: number } } }>(obj: T)
// Проще: создать интерфейс и использовать его
interface NestedConfig { a: { b: { c: number } }; }
function complex<T extends NestedConfig>(obj: T)
// function process(data: any) { ... }
// Хорошо: extends ограничивает тип
// function process<T extends HasId>(data: T) { ... }
Следующий урок
Утилитарные типы: Partial, Required, Pick, Omit — разберём встроенные типы для трансформации типов
Тест по ограничениям generics
8 вопросов