$ sudo teach IT
МОДУЛЬ 6 · УРОК 4

Ограничения 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, чтобы он соответствовал определённой структуре:

// T ограничен: должен иметь свойство length типа number
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 для интерфейсов

Вы можете ограничить тип конкретным интерфейсом:

// Интерфейс с обязательным полем id
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, который возвращает объединение тип всех ключей объекта:

// keyof возвращаетобъединение тип ключей
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 — возвращаетобъединение тип всех ключей T
  • K extends keyof T — K должен быть одним из ключей T
  • T[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 с несколькими ограничениями

Вы можете ограничить тип несколькими условиями, используя пересечение типов:

// T должен иметь id И name
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 для создания условных типов:

// Условный тип: если T — строка, вернуть number, иначе — T
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 для:
  • Ограничения типов по структуре
  • Создания типобезопасных утилит
  • Валидации ключей объектов
  • Условных типов
Избегайте:
  • Слишком сложных ограничений
  • Избыточных ограничений, когда они не нужны
  • Использования extends вместо any
  • Забывания про keyof

Итоги урока

  • extends ограничивает тип T, чтобы он соответствовал определённой структуре
  • keyof T возвращаетобъединение тип всех ключей объекта T
  • T[K] — indexed access type: тип значения по ключу
  • Можно ограничивать несколькими условиями через пересечение типов
  • extends работает с интерфейсами, классами и литеральными типами
  • extends позволяет создавать типобезопасные утилиты
  • Используйте extends для создания условных типов

Практический пример: типобезопасный map

Функция 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, которая аккумулирует значения с сохранением типов:

// Типобезопасная функция 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, которая сохраняет контекст вызова:

// 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:

Ошибка 1: Забыли указать keyof
// Неправильно:
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];
}
Ошибка 2: Слишком сложные ограничения
// Слишком сложно:
// function complex<T extends { a: { b: { c: number } } }>(obj: T)
// Проще: создать интерфейс и использовать его
interface NestedConfig { a: { b: { c: number } }; }
function complex<T extends NestedConfig>(obj: T)
Ошибка 3: Используют extends вместо any
// Плохо: any отключает типизацию
// function process(data: any) { ... }
// Хорошо: extends ограничивает тип
// function process<T extends HasId>(data: T) { ... }

Следующий урок

Утилитарные типы: Partial, Required, Pick, Omit — разберём встроенные типы для трансформации типов

Тест по ограничениям generics

8 вопросов

Длина с ограничением

Premium