1. Продвинутые дженерики, ограничения типов и сопоставляемые типы (Mapped Types)
Продвинутые дженерики, ограничения типов и сопоставляемые типы (Mapped Types)
Добро пожаловать в курс TypeScript Pro: Глубокое погружение в систему типов. Мы начинаем наше путешествие с фундаментальных, но часто неправильно понимаемых инструментов, которые превращают TypeScript из простого линтера в мощный инструмент архитектуры программного обеспечения.
Многие разработчики останавливаются на базовом понимании дженериков, используя их как «заглушки» для типов. Однако истинная сила TypeScript раскрывается, когда мы начинаем ограничивать эти дженерики и трансформировать их с помощью сопоставляемых типов (Mapped Types). В этой статье мы разберем, как создавать гибкие, но безопасные абстракции.
Дженерики: за пределами <T>
Дженерики (Generics) часто сравнивают с аргументами функций, но для типов. Если функция принимает значения и возвращает значение, то дженерик принимает тип и возвращает новый тип.
Рассмотрим классический пример функции идентификации, но взглянем на него глубже:
Здесь T — это переменная типа. Когда мы вызываем identity("Hello"), TypeScript подставляет string вместо T. Это называется выводом типов (type inference).
Дженерики по умолчанию
Иногда нам нужно, чтобы дженерик имел тип «по умолчанию», если разработчик не указал его явно и TypeScript не смог его вывести. Это похоже на параметры по умолчанию в функциях ES6.
Это позволяет делать код более чистым, скрывая сложные типы конфигурации, которые часто остаются стандартными.
Ограничения дженериков (Generic Constraints)
Самая большая проблема с «голым» дженериком <T> заключается в том, что TypeScript ничего о нем не знает. Для компилятора T — это абсолютно любой тип. Это значит, что вы не можете безопасно обращаться к свойствам этого типа.
Попробуем написать функцию, которая возвращает длину элемента:
TypeScript прав: а что, если T будет числом (number)? У чисел нет свойства .length. Чтобы исправить это, мы должны ограничить дженерик. Мы говорим компилятору: «Я не знаю точно, что это за тип, но я обещаю, что у него точно будет свойство length».
Для этого используется ключевое слово extends:
Теперь мы можем передавать строки, массивы или объекты с полем length, но не числа или булевы значения.
!Визуализация принципа работы ограничений типов (Constraints), где extends работает как фильтр.
Использование параметров типа в ограничениях
Очень мощный паттерн — использование одного дженерика для ограничения другого. Это часто используется при работе с объектами и их ключами.
Представьте функцию, которая безопасно получает значение свойства из объекта:
Здесь K extends keyof T означает, что K может быть только одним из ключей объекта T. Если T — это { x: number, y: number }, то K может быть только "x" или "y".
Сопоставляемые типы (Mapped Types)
Если дженерики — это функции для типов, то Mapped Types — это циклы для типов. Они позволяют создавать новые типы, перебирая ключи существующего типа.
Синтаксис напоминает цикл for...in в JavaScript, но внутри квадратных скобок определения типа.
Анатомия Mapped Type
Разберем по частям:
keyof T: получает объединение всех ключей типа T (например, "name" | "age").P in ...: это итератор. P будет последовательно принимать значение каждого ключа.T[P]: это доступ к типу значения по ключу P (lookup type).В таком виде MappedType<T> просто создает копию типа T. Но мощь заключается в трансформации.
Пример: Readonly и Partial
В TypeScript есть встроенные утилиты Readonly<T> и Partial<T>. Давайте посмотрим, как они реализованы внутри, используя Mapped Types.
Создание Partial (все поля необязательные):
Знак вопроса ? делает каждое поле необязательным.
Создание Readonly (все поля только для чтения):
Ключевое слово readonly добавляет модификатор неизменяемости к каждому полю.
Модификаторы сопоставления (+ и -)
Иногда нам нужно не добавить модификатор, а удалить его. Например, у вас есть тип, где все поля readonly и опциональны, а вы хотите сделать их изменяемыми и обязательными (например, для формы редактирования).
Для удаления модификаторов используется префикс -.
* -?: удаляет опциональность (делает поле обязательным).
* -readonly: удаляет флаг только для чтения.
Пример утилиты MutableRequired:
Переименование ключей с помощью as
Начиная с TypeScript 4.1, мы можем не только менять типы значений, но и переименовывать сами ключи, используя конструкцию as и шаблонные литералы (Template Literal Types).
Допустим, мы хотим создать геттеры для всех полей объекта:
typescript interface SignUpForm { email: string; password: string; age: number; }
// Мы хотим создать тип, где каждое поле соответствует полю формы, // но содержит либо сообщение об ошибке (string), либо null. type ValidationErrors<T> = { [P in keyof T]?: string | null; };
const errors: ValidationErrors<SignUpForm> = {
email: "Invalid email format",
// password и age могут отсутствовать или быть null
};
``
Без Mapped Types вам пришлось бы вручную описывать интерфейс SignUpFormErrors, дублируя имена полей. При изменении SignUpForm вам пришлось бы менять и тип ошибок. С Mapped Types это происходит автоматически.
Заключение
Мы рассмотрели три мощных столпа продвинутого TypeScript:
В следующей статье мы углубимся в условные типы (Conditional Types) и ключевое слово infer`, которые позволят нам писать логику "если-то" прямо внутри системы типов.