Урок 35: Ограничения Generics (extends keyof) в TypeScript

Мы с вами уже проделали огромный путь, изучив базовые типы, функции, классы и конечно же дженерики, мощнейший инструмент для создания универсального и переиспользуемого кода.

Но помните, в одном из прошлых уроков я упомянул, что с большой силой приходит и большая ответственность? Дженерики, при всей их гибкости, могут быть слишком вольными. Что, если мы хотим создать функцию, которая работает не с чем угодно, а только с чем-то конкретным? Например, функция, которая принимает только объекты или функция, которая извлекает значение свойства, но мы хотим гарантировать, что такое свойство действительно существует у переданного объекта.

Вот здесь на сцену и выходят два неразлучных друга, оператор extends для создания ограниченийи оператор keyof. Сегодня, на уроке 35, мы научимся использовать их вместе, чтобы наши дженерики стали не только гибкими, но и предсказуемыми, безопасными.

Зачем нужны ограничения?

Давайте вспомним, как работают обычные дженерики. Мы объявляем параметр типа, например, <T> и затем используем его внутри функции, интерфейса или класса. TypeScript позволяет подставить на место T абсолютно любой тип.

Вот классический пример функции, которая возвращает первый элемент массива:

typescript
function getFirstElement<T>(arr: T[]): T | undefined {
    return arr[0];
}

const num = getFirstElement([1, 2, 3]); // type: number
const str = getFirstElement(['a', 'b', 'c']); // type: string

Всё работает прекрасно. Но давайте попробуем написать что-то более сложное. Допустим, мы хотим создать функцию, которая принимает объект и ключ, а возвращает значение этого ключа из объекта.

Наша первая наивная попытка может выглядеть так:

typescript
function getValue<T, U>(obj: T, key: U) {
    return obj[key];
    // ОШИБКА: Type 'U' cannot be used to index type 'T'.
}

И сразу же мы сталкиваемся с ошибкой. TypeScript это прежде всего система типов для безопасности. Он видит, что T это какой-то произвольный тип (это может быть number, string, вообще что угодно). И U это тоже какой-то произвольный тип. Компилятор не может гарантировать, что значение типа U можно использовать в качестве ключа для доступа к свойствам объекта типа T. В самом деле, как можно быть уверенным, что если T это число, а U это строка 'name', то выражение obj[key] будет безопасным? Это nonsensical (бессмысленное) выражение с точки зрения типов.

Нам нужно каким-то образом ограничить тип U. Мы должны сказать TypeScript: «Успокойся, дружище. U не будет каким угодно типом. Это будет только тот тип, который является ключом объекта T«. Именно это мы и будем делать сегодня.

Спасительный оператор extends для ограничения типов

Чтобы наложить ограничения на параметр типа, мы используем ключевое слово extends. Синтаксис очень простой:

typescript
function functionName<T extends ConstraintType>(arg: T) {
  // ...тело функции
}

Здесь мы говорим: «Параметр типа T может быть любым типом, но он обязан быть подтипом ConstraintType или совпадать с ним». ConstraintType это ограничивающий тип, который задаёт границы дозволенного для T.

Давайте рассмотрим простой пример, прежде чем переходить к нашей функции с объектом.

Пример 1: Функция для работы с объектами, имеющими имя

Предположим, мы хотим написать функцию, которая возвращает длину имени объекта. Мы хотим, чтобы эта функция работала с любым объектом, у которого есть свойство name типа string.

Без ограничений мы могли бы написать так, но это было бы небезопасно:

typescript
function getNameLength<T>(obj: T): number {
    return obj.name.length;
    // ОШИБКА: Property 'name' does not exist on type 'T'.
}

Компилятор справедливо ругается: «А вдруг в T нет свойства name?». Мы ограничим T так, чтобы он обязательно имел свойство name.

typescript
interface HasName {
    name: string;
}

function getNameLength<T extends HasName>(obj: T): number {
    // Теперь TypeScript уверен, что у obj есть свойство name типа string.
    return obj.name.length;
}

// Теперь эта функция работает с любым объектом, у которого есть name: string
const person = { name: 'Max', age: 30 };
const company = { name: 'Apple Inc.', ticker: 'AAPL' };

getNameLength(person); // OK, 3
getNameLength(company); // OK, 10

getNameLength({ city: 'Moscow' }); // ОШИБКА: Argument of type '{ city: string; }' is not assignable to parameter of type 'HasName'.

Вот оно! Мы создали ограничивающий интерфейс HasName. С помощью T extends HasName мы сказали: «T может быть чем угодно (например, объектом с полями nameageticker), но он обязан включать в себя структуру HasName, то есть иметь свойство name: string«.

Это и есть базовая идея ограничений. Мы сужаем круг типов, с которыми может работать дженерик, делая код безопасным и предсказуемым.

Оператор keyof: получаем ключи типа

Теперь давайте познакомимся с оператором keyof. Это очень простой, но крайне полезный оператор. Он принимает тип и возвращает юнион тип (union type) из его ключей (названий свойств).

Проще всего это понять на примере.

typescript
interface Person {
    name: string;
    age: number;
    location: string;
}

type PersonKey = keyof Person;
// Эквивалентно: type PersonKey = "name" | "age" | "location"

const key1: PersonKey = 'name'; // OK
const key2: PersonKey = 'age'; // OK
const key3: PersonKey = 'location'; // OK
const key4: PersonKey = 'email'; // ОШИБКА: Type '"email"' is not assignable to type 'keyof Person'.

Оператор keyof создаёт тип, который может быть только одним из строковых литералов, представляющих имена свойств интерфейса Person. Это идеальный инструмент, когда мы хотим гарантировать, что строка является именно ключом конкретного объекта, а не любой строкой.

Задача 1: Практика с keyof
Создайте интерфейс Car с полями brand: stringmodel: stringyear: number. Создайте тип CarKey с помощью keyof. Попробуйте присвоить переменной этого типа разные строки, в том числе не являющиеся ключами Car.

Решение:

typescript
interface Car {
    brand: string;
    model: string;
    year: number;
}

type CarKey = keyof Car; // "brand" | "model" | "year"

const validKey: CarKey = 'brand'; // OK
const invalidKey: CarKey = 'color'; // ОШИБКА: Type '"color"' is not assignable to type 'keyof Car'.

extends keyof для безопасного доступа к свойствам

А теперь соберём всё вместе! Мы вспомнили нашу изначальную проблему: функция getValue(obj, key) не работала, потому что TypeScript не мог гарантировать, что key является ключом obj.

У нас есть два инструмента:

  1. extends, чтобы наложить ограничение.

  2. keyof, чтобы получить тип всех ключей.

Мы можем создать ограничение для типа ключа UU extends keyof T.

Давайте прочитаем эту запись: «Тип U должен расширять (быть подтипом) типа keyof T«. Поскольку keyof T это юнион из строковых литералов (например, "name" | "age" | "location"), его единственно возможными подтипами являются он сам или его более узкие части (например, только "name" или только "age" | "location"). Это именно то, что нам нужно!

Перепишем нашу функцию:

typescript
function getValue<T, U extends keyof T>(obj: T, key: U): T[U] {
    // Всё чисто! TypeScript теперь знает, что key - это точно ключ объекта T.
    return obj[key];
}

Разберём её по косточкам:

  • <T, U extends keyof T>: Объявляем два параметра типа. T тип нашего объекта. U тип нашего ключа, который ограничен только теми значениями, которые являются ключами T.

  • (obj: T, key: U): Функция принимает объект типа T и ключ типа U.

  • : T[U]: Возвращаемый тип это тип свойства T[U]. Это называется lookup type (или indexed access type). TypeScript очень умён: он посмотрит, какой тип стоит у свойства U внутри типа T и вернёт его. Если T это Person, а U это "age", то возвращаемый тип будет number.

Пример в действии:

typescript
interface Person {
    name: string;
    age: number;
    location: string;
}

const person: Person = {
    name: 'Alice',
    age: 32,
    location: 'London'
};

const nameValue = getValue(person, 'name'); // type: string
const ageValue = getValue(person, 'age'); // type: number
const locationValue = getValue(person, 'location'); // type: string

getValue(person, 'occupation'); // ОШИБКА: Argument of type '"occupation"' is not assignable to parameter of type 'keyof Person'.

Мы достигли полной типобезопасности:

  1. Мы не можем передать ключ, которого нет в объекте.

  2. Тип возвращаемого значения вычисляется автоматически и точно соответствует типу свойства.

  3. Наша функция остаётся универсальной и будет работать с любым объектом!

Задача 2: Напишите функцию setValue<T, U extends keyof T>(obj: T, key: U, value: T[U]): void, которая безопасно устанавливает значение свойства объекта.
Функция должна принимать объект, ключ и новое значение для этого ключа. TypeScript должен предотвращать попытки установить значение неверного типа.

Решение:

typescript
function setValue<T, U extends keyof T>(obj: T, key: U, value: T[U]): void {
    obj[key] = value;
}

const myPerson: Person = { name: 'Bob', age: 25, location: 'Paris' };

setValue(myPerson, 'age', 26); // OK
setValue(myPerson, 'name', 'Charlie'); // OK
setValue(myPerson, 'age', 'old'); // ОШИБКА: Type 'string' is not assignable to type 'number'.
setValue(myPerson, 'password', '12345'); // ОШИБКА: Argument of type '"password"' is not assignable to parameter of type 'keyof Person'.

Обратите внимание, как T[U] в типе параметра value обеспечивает проверку типа значения. Мы не можем передать число для свойства name (string) и наоборот.

Практические примеры и задачи

Давайте закрепим материал на более сложных и практически полезных примерах.

Пример 1: Функция для сортировки массива объектов по полю

Очень частая задача: отсортировать массив объектов по определённому полю. С дженериками и ограничениями мы можем написать универсальное и типобезопасное решение.

typescript
function sortByField<T, U extends keyof T>(arr: T[], field: U): T[] {
    // Для простоты предположим, что поле field всегда имеет тип, который можно сравнить через > и <
    return [...arr].sort((a, b) => {
        if (a[field] < b[field]) return -1;
        if (a[field] > b[field]) return 1;
        return 0;
    });
}

interface User {
    name: string;
    score: number;
}

const users: User[] = [
    { name: 'Alice', score: 42 },
    { name: 'Bob', score: 37 },
    { name: 'Clara', score: 51 },
];

// Сортируем по score
const sortedByScore = sortByField(users, 'score');
console.log(sortedByScore); // [{name: 'Bob', ...}, {name: 'Alice', ...}, {name: 'Clara', ...}]

// Сортируем по name
const sortedByName = sortByField(users, 'name');
console.log(sortedByName); // [{name: 'Alice', ...}, {name: 'Bob', ...}, {name: 'Clara', ...}]

// sortByField(users, 'email'); // Ошибка: такого поля нет

Эта функция будет работать для любого массива объектов и любого поля внутри них!

Задача 3: Улучшите функцию sortByField, добавив возможность указать направление сортировки (ascending/descending). Сигнатура функции должна стать такой: sortByField<T, U extends keyof T>(arr: T[], field: U, direction: 'asc' | 'desc' = 'asc'): T[].

Решение:

typescript
function sortByField<T, U extends keyof T>(arr: T[], field: U, direction: 'asc' | 'desc' = 'asc'): T[] {
    const sortOrder = direction === 'asc' ? 1 : -1;
    return [...arr].sort((a, b) => {
        if (a[field] < b[field]) return -1 * sortOrder;
        if (a[field] > b[field]) return 1 * sortOrder;
        return 0;
    });
}

const usersDesc = sortByField(users, 'score', 'desc');
console.log(usersDesc); // [{name: 'Clara', ...}, {name: 'Alice', ...}, {name: 'Bob', ...}]

Пример 2: Функция для выборки нескольких свойств из объекта (pick)

В функциональном программировании часто используется функция pick, которая создаёт новый объект, выбрав из старого только указанные свойства.

typescript
function pick<T, U extends keyof T>(obj: T, keys: U[]): Pick<T, U> {
    const result = {} as Pick<T, U>;
    for (const key of keys) {
        result[key] = obj[key];
    }
    return result;
}

const person = { name: 'Max', age: 30, city: 'Moscow', country: 'Russia' };

const nameAndAge = pick(person, ['name', 'age']);
// Тип nameAndAge: Pick<Person, "name" | "age">
// Значение: { name: 'Max', age: 30 }

// pick(person, ['name', 'phone']); // Ошибка: 'phone' отсутствует в типе

Здесь мы используем встроенный тип Pick<T, U>, который создаёт тип, выбрав из T только свойства U. Обратите внимание, как возвращаемый тип функции выводится автоматически и является максимально точным.

Задача 4: Реализуйте противоположную функцию omit<T, U extends keyof T>(obj: T, keys: U[]): Omit<T, U>, которая создаёт объект, исключия указанные свойства.

Решение:

typescript
function omit<T, U extends keyof T>(obj: T, keys: U[]): Omit<T, U> {
    const result = { ...obj };
    for (const key of keys) {
        delete result[key];
    }
    return result;
}

const withoutAge = omit(person, ['age']);
// Тип withoutAge: Omit<Person, "age">
// Значение: { name: 'Max', city: 'Moscow', country: 'Russia' }

Пример 3: Работа с API и частичные обновления

Часто при работе с API отправляются не целые объекты, а только поля, которые нужно изменить (PATCH-запросы). Мы можем типизировать это.

typescript
interface Article {
    id: number;
    title: string;
    content: string;
    author: string;
    published: boolean;
}

// Функция для отправки частичного обновления
function updateArticle(id: number, fieldsToUpdate: Partial<Article>) {
    // Отправляем запрос типа PATCH на сервер
    // /api/articles/${id}, body: fieldsToUpdate
}

// Мы можем сделать свой "строгий" Partial, который разрешает только существующие ключи
function updateArticleStrict<T, U extends keyof T>(id: number, field: U, value: T[U]) {
    // Отправляем запрос, обновляя одно поле
}

updateArticle(1, { title: 'New Title' }); // OK
updateArticle(1, { tags: ['js'] }); // ОШИБКА: 'tags' does not exist in type 'Partial<Article>'

updateArticleStrict(1, 'title', 'New Title'); // OK
updateArticleStrict(1, 'title', 123); // ОШИБКА: Type 'number' is not assignable to type 'string'
updateArticleStrict(1, 'tags', ['js']); // ОШИБКА: Argument of type '"tags"' is not assignable to parameter of type 'keyof Article'

Итоги

Вот мы и разобрали один из самых важных и элегантных механизмов в TypeScript, ограничение дженериков с помощью extends keyof. Давайте подведём краткие итоги:

  1. extends (Ограничения). Позволяет сузить круг типов, которые можно подставить в generic. Мы гарантируем, что тип обладает определёнными характеристиками (например, имеет свойство name).

  2. keyof. Оператор, который создаёт union type из ключей (строковых литералов) другого типа. Нужен для описания типа, который может быть только одним из ключей объекта.

  3. extends keyof. Комбинация, которая создаёт типобезопасную связь между объектом и его ключом. Это фундамент для функций, которые работают со свойствами объектов (getsetpicksortBy и т.д.).

  4. T[U] (Lookup Type). Позволяет получить тип свойства объекта U у типа T. Обеспечивает корректную типизацию возвращаемых значений и параметров.

Эти инструменты кардинально меняют то, как вы пишете код на TypeScript. Вы перестаёте просто описывать типы и начинаете конструировать и манипулировать ими, создавая универсальные, переиспользуемые и абсолютно безопасные функции и компоненты. Это тот уровень владения языком, который отличает новичка от продвинутого разработчика.

Поиграйтесь с этими концепциями в вашем проекте или в песочнице TypeScript. Как только вы почувствуете себя комфортно с extends keyof, вы по-настоящему поймёте силу системы типов TypeScript.

На сегодня всё. В следующем уроке мы поговорим о ещё более продвинутых возможностях системы типов.

Полный курс с уроками по TypeScript для начинающих

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий