
Ранее на Piccalilli Сэм Роуз поделился реальными примерами использования вспомогательных типов (utility types) TypeScript. Сегодня я хочу продолжить эту тему и поделиться несколькими продвинутыми возможностями TypeScript для работы с типами, которые, на мой взгляд, особенно полезны и применимы в реальных проектах.
Цель этой статьи — дать общее представление о каждой из возможностей, а также привести пример ее использования, чтобы вы могли лучше ориентироваться в том, какие инструменты можно использовать при создании типов.
Прежде чем перейти к основному материалу, я хочу объяснить, зачем вообще стоит применять эти возможности, поскольку на первый взгляд они могут показаться довольно сложными.
❯ Немного полезной теории
Иногда типы в TypeScript воспринимаются просто как некие "украшения" или удобные надстройки над обычным JS-кодом. И я не стану отрицать — с таким подходом действительно можно многое сделать! Однако настоящая сила и гибкость TypeScript раскрываются для тех, кто воспринимает типы как неотъемлемую часть архитектуры программы.
При разработке программного обеспечения, мы всегда так или иначе строим модель для решения определенной задачи или отражения части реального мира — осознанно или нет. Типы помогают формализовать эту модель, описывая данные, с которыми работает наша программа. Классы и функции описывают поведение, а типы — структуру данных. Это две стороны одной медали.
В CSS мы выносим общие элементы дизайна в переиспользуемые единицы (например, классы) и сочетаем их друг с другом через каскад. Такие практики абстракции и композиции делают код поддерживаемым и понятным. С типами все то же самое: продвинутые возможности TypeScript помогают выделять общие черты и комбинировать разные типы между собой.
Для создания одного и того же визуального эффекта мы можем использовать и Flexbox, и Grid, но выбираем конкретное решение в зависимости от контекста. То же самое касается и моделирования данных с помощью типов: нужно осознанно выбирать, какую сложность мы готовы принимать ради нужной модели. Другими словами, сам по себе факт существования какой-то возможности в языке не означает, что ее всегда следует использовать. Это в значительной степени относится к мощным (но сложным) возможностям, которые мы рассмотрим сегодня.
❯ Практический пример
Допустим, мы пишем код для системы контроля за домом (а-ля умный дом) и хотим отправлять событие каждый раз, когда дома происходит что-то важное — например, открывается дверь или датчик влаги обнаруживает воду.
В итоге у нас может получиться примерно такой код:
type EventName =
| 'open'
| 'close'
| 'locked'
| 'unlocked'
| 'moisture'
| 'motion';
type Access = {
sensorId: string;
// Допустим, дверь открывается с помощью кодовой панели,
// и у каждого человека есть свой личный код.
openedBy: string;
// Время в миллисекундах с начала эпохи Unix.
// Для реального использования это не лучший вариант,
// но так примеры можно запускать без изменений.
timestamp: number;
};
type Moisture = {
sensorId: string;
timestamp: number;
};
type Motion = {
cameraId: string;
timestamp: number;
};
const sendEvent = (
event: EventName,
payload: Access | Moisture | Motion
) => { /* ... */ };
Тип EventName
описывает список возможных событий, в то время как события Access
, Moisture
и Motion
описывают данные, которые мы хотим связать с конкретным событием. Проблема этого кода заключается в том, что он не связывает конкретные имена событий с соответствующей полезной нагрузкой (payload)!
Мы можем связать события и данные, введя новый тип:
type HomeEvents = {
open: Access;
close: Access;
locked: Access;
unlocked: Access;
moisture: Moisture;
motion: Motion;
};
Ключи типа HomeEvents
описывают возможные имена событий, а значения — возможную полезную нагрузку этих событий. Интересно то, что мы никогда не будем создавать значение этого типа. Мы будем использовать его только для генерации других типов.
Сам по себе HomeEvents
не особо полезен, но в сочетании с типами доступа по индексу/индексированными типами доступа(indexed access types) он позволит нам продвинуться вперед!
Индексированные типы доступа — это типовой эквивалент обычного доступа к свойствам в JS:
type Company = {
name: String;
revenue: number;
}
const edison: Company = {
name: "Edison Power Co",
revenue: 9001
};
const r = edison['revenue']; // 9001
type Revenue = Company['revenue']; // number
Работая с обычным объектом в JS, мы можем получить значение свойства, используя нотацию с квадратными скобками []
. При работе с типом объекта мы можем использовать ту же нотацию, чтобы получить тип конкретного свойства. Однако мы не ограничены только литеральными типами, такими как revenue
. В квадратных скобках можно использовать различные типы:
Company['name' | 'revenue'] // string | number
С этим новым инструментом мы можем попробовать обновить код следующим образом:
type Access = {
sensorId: string;
openedBy: string;
timestamp: number;
};
type Moisture = {
sensorId: string;
timestamp: number;
};
type Motion = {
cameraId: string;
timestamp: number;
};
type HomeEvents = {
open: Access;
close: Access;
locked: Access;
unlocked: Access;
moisture: Moisture;
motion: Motion;
};
const sendEvent = (
event: string,
payload: HomeEvents[string]
) => { /* ... */ };
Но получаем ошибку: Type 'HomeEvents' has no matching index signature for type 'string'.(2537)
. Простыми словами, HomeEvents
нельзя использовать с любым ключом. Нам нужно ограничить возможные значения, которые могут быть использованы для event
. Один из способов сделать это — вернуть тип EventName
:
type EventName =
| 'open'
| 'close'
| 'locked'
| 'unlocked'
| 'moisture'
| 'motion';
type HomeEvents = {
open: Access;
close: Access;
locked: Access;
unlocked: Access;
moisture: Moisture;
motion: Motion;
};
const sendEvent = (
event: EventName,
// HomeEvents[string] -> HomeEvents[EventName]
payload: HomeEvents[EventName]
) => { /* ... */ };
Теперь код выполняется, но все еще существует две проблемы:
- Мы определяем список событий дважды.
- Типы
event
иpayload
все еще не связаны между собой.
Мы можем решить первую проблему с помощью другого инструмента — оператора keyof
. Оператор keyof
принимает тип объекта и создает объединение всех его ключей:
type Bird = {
species: string;
color: string;
age: number;
};
// Эти два типа эквивалентны
type BirdProperties = keyof Bird;
type _BirdProperties = 'species' | 'color' | 'age'
Чтобы устранить дублирование между типами EventName
и HomeEvents
, мы можем вычислить EventName
напрямую из HomeEvents
с использованием keyof
:
type HomeEvents = {
open: Access;
close: Access;
locked: Access;
unlocked: Access;
moisture: Moisture;
motion: Motion;
};
type EventName = keyof HomeEvents;
const sendEvent = (
event: EventName,
payload: HomeEvents[EventName]
) => { /* ... */ };
Это порождает еще одну проблему — на уровне типов нет механизма, который бы предотвращал несоответствие между именами событий и их полезными нагрузками. Например, мы можем вызвать sendEvent
с событием "motion"
, но передать полезную нагрузку для события Access
!
Мы можем исправить это с помощью общих типов (generic types). Это изменение более сложное, поэтому я приведу конечный результат, а затем объясню его шаг за шагом:
/*
const sendEvent = (
event: EventName,
payload: HomeEvents[EventName]
) => {};
*/
const sendEvent = <E extends EventName,>(
event: E,
payload: HomeEvents[E]
) => { /* ... */ };
Завершающая запятая
Синтаксис JSX и общих типов в TypeScript пересекается при использовании стрелочных функций. В таких случаях запятая в конце помогает парсеру и подсветке синтаксиса распознать, что мы пишем общий тип, а не JSX-элемент.
Если такой подход кажется вам неудобным, можно использовать обычную функцию:
function sendEvent<E extends EventName>(
event: E,
payload: HomeEvents[E]
) { /* */ }
В итоговом варианте мы начинаем с добавления универсального параметра типа E
в функцию sendEvent
. Его можно представить как параметр функции или переменную — только не на уровне значений, а на уровне типов.
Рассмотрим аналогию с обычными функциями. Конечно, мы можем просто складывать числа напрямую, например: const sum = 2 + 2
. Но если мы вводим имена для значений и работаем только с этими именами, забывая о конкретных числах, то можем написать обобщенную функцию: const add = (x, y) => x + y;
.
Параметры x
и y
в этой функции — это своего рода "дженерики" для значений, которые будут переданы при вызове. То же самое и с общими типами: мы создаем именованный "заполнитель" (placeholder) для типа, который будет подставлен позже, при использовании функции.
Однако если бы мы могли лишь объявлять такие типы без дополнительной логики, пользы от них было бы немного. Как в этом примере:
const log = <T,>(x: T) {
console.log(x);
}
Поскольку T
может быть любым типом, параметр функции x
по сути оказывается не типизированным. Если бы это было все, что мы можем делать с общими типами, их практическая ценность была бы невелика. Вот здесь и приходит на помощь ключевое слово extends
.
Ключевое слово extends
позволяет наложить ограничения на допустимые значения общего типа — такие ограничения часто называют границами общих типов (generic type bounds). Если продолжать аналогию с функциями, такие ограничения похожи на указание типов параметров функции:
const add = (x: number, y: number) => x + y;
// ---
type Pet {
name: string;
age: number;
}
type Fish = Pet & {
waterType: 'fresh' | 'salt';
};
type Cat = Pet & {
cuteness: number;
};
type Dog = Pet & {
tailWagLevel: number;
};
const feedPet = <P extends Pet,>(pet: P) => { /* */ };
x
и y
— это заполнители для значений, но тип number
, накладывает ограничение, позволяя передавать только числа.
Аналогично, универсальный тип P
в функции feedPet
ограничен типом Pet
, что означает: он может быть заменен только на Pet
или любой его подтип, например Cat
или Dog
.
Вот что означает extends
в TypeScript:
Если тип T
расширяет тип U
(T extends U), то значения типа T
можно использовать везде, где ожидается значение типа U
.
Звучит знакомо?
Вы, вероятно, слышали об этом как о Принципе подстановки Лисков (Liskov Substitution Principle, LSP).
❯ Возвращаемся к исходному примеру
Что это все значит для функции sendEvent
? Это значит, что тип event
должен быть подмножеством типа EventName
.
Давайте вручную определим типы, которые будут использоваться компилятором:
// Начнем с универсальной функции
const sendEvent = <E extends EventName,>(
event: E,
payload: HomeEvents[E]
) => { /* ... */ };
// Когда мы указываем имя `event`, компилятор может
// автоматически подставить остальные типы
sendEvent('motion', /* Какой тип здесь разрешен? */);
// Шаг 1:
const sendEvent = <'motion' extends EventName,>(
event: 'motion',
payload: HomeEvents['motion']
) => { /* ... */ };
// Шаг 2:
const sendEvent = <'motion' extends EventName,>(
event: 'motion',
payload: Motion
) => { /* ... */ };
Общий тип связывает тип event
и тип payload
таким образом, что невозможно перепутать имя события с его полезной нагрузкой.
Конечно, это всего лишь один пример того, как можно использовать keyof
, дженерики и типы доступа по индексу вместе. Если у вас имеется опыт работы с TypeScript, или вы уже использовали исключающие объединения (discriminated unions), возможно, вам в голову придут другие способы проектирования типов для функции sendEvent
. Дерзайте ;)
❯ Отображаемые типы
Рассмотрим еще одну продвинутую возможность TypeScript — отображаемые типы (mapped types).
Чтобы понять, что такое отображаемые типы, начнем с обсуждения самой идеи "отображения" в программировании. Отображение обычно означает применение функции к каждому элементу множества/коллекции для получения обновленного множества, как в следующем примере:
const countingNumbers = [1,2,3,4,5];
const evens = countingNumbers.map(n => n * 2);
console.log(evens);
// [2,4,6,8,10]
Эту идею легко перенести на объектные типы, проходя по парам "ключ-значение" (хотя синтаксис, надо признать, достаточно сложный):
const grossRevenues = {
"Edison Power Co.": 5760,
"Best Ever Kebabs": 9756,
"Tarjan & Co. Networking Service": 10755,
"Ford Fulkerson Plumbing": 42665
};
const applyTaxes = (
businessName: string,
revenue: number
) => [businessName, revenue - revenue * 0.20] as const;
const postTaxRevenue = Object.fromEntries(
Object.entries(grossRevenues).map(
([key, value]) => applyTaxes(key, value)
)
);
/* Альтернатива:
Object.entries(grossRevenues).reduce((acc, [k, v]) => {
const [newKey, newValue] = applyTaxes(k, v);
acc[newKey] = newValue;
return acc;
}, {} as Record<string, number>);
*/
Роль as const
Иногда TypeScript выводит слишком общий тип. Без использования as const
возвращаемый тип для функции applyTaxes
будет (string | number)[]
, что может вызвать проблемы в дальнейшем, так как TypeScript будет считать, что типом переменной key
является string | number
.
Более точный возвращаемый тип для applyTaxes
— это кортеж: readonly [string, number]
, но для того, чтобы TypeScript использовал этот тип вместо более общего (string | number)[]
, необходимо добавить as const
.
В приведенном примере мы сопоставляем каждую компанию и ее валовый доход с новой парой ключ-значение, описывающей ее доход после уплаты налогов. Мы могли бы выполнить более сложные преобразования, например, переименовать ключи, удалить их из результирующего объекта, создать несколько выходных ключей для одного входного и т.д.
Причина, по которой я это упоминаю, заключается в том, что отображаемые типы позволяют делать нечто подобное, но на уровне типов.
Рассмотрим пример, который расширяет систему контроля за домом, о которой шла речь ранее. Предположим, что нам нужен тип, который будет описывать наличие в системе определенного датчика. Но при этом мы не хотим повторять весь список типов событий, используемый ранее:
type HomeEvents = {
open: Access;
close: Access;
locked: Access;
unlocked: Access;
moisture: Moisture;
motion: Motion;
};
// Отображаемый тип
type HasSensors = {
[SensorName in keyof HomeEvents]: boolean;
};
Тип HasSensors
расширяется до:
type HasSensors = {
open: boolean;
close: boolean;
locked: boolean;
unlocked: boolean;
moisture: boolean;
motion: boolean;
};
// Некоторые из этих ключей, такие как "close", не имеют смысла в контексте сенсоров.
// Но для примера этого достаточно.
Отображаемые типы всегда создают типы объектов, поэтому не удивительно, что синтаксис имеет некоторые сходства. Мы начинаем с фигурных скобок, а затем внутри пишем выражение, которое описывает каждую пару ключ-значение в объекте. "Сторона ключа" выражения выглядит как [KeyType in KeySourceType]
, ее можно представить как цикл for
:
for (let i of [1,2,3]) {
console.log(i);
}
В приведенном сниппете i
принимает значения каждого из элементов массива: сначала 1
, потом 2
и, наконец, 3
.
В отображаемом типе KeyType
— это как i
, а KeySourceType
— это как массив. В примере с HasTypes
, SensorName
сначала равен 'open'
, затем 'close'
и т.д., пока не примет все возможные значения из keyof HomeEvents
. Мы как бы "перебираем" — или, говоря иначе, проходим по всем вариантам из KeySourceType
.
Задача "стороны ключа" (слева от двоеточия) — это определение имен ключей в новом типе объекта. Задача "стороны значения" (справа от двоеточия) — это определение типов соответствующих значений.
Когда мы пишем:
type HasSensors = {
[SensorName in keyof HomeEvents]: boolean;
};
Мы говорим TS: "Создай объектный тип из ключей типа HomeEvents
и назначь каждому свойству тип boolean
".
На этом этапе можно подумать: "Это не очень полезно", но отображаемые типы могут гораздо больше. Во-первых, они могут быть (и часто являются) общими типами, поэтому мы можем улучшить тип HasSensors
, чтобы он работал с любым объектом:
type BooleanProperties<Type> = {
[Key in keyof Type]: boolean;
};
Тип MakeBoolean
работает аналогично HasSensors
, за исключением того, что теперь мы можем применять его к любому объекту:
type DrawingTools = {
pencil: () => {};
fill: () => {};
pen: () => {};
};
type WhichToolsActive = BooleanProperties<DrawingTools>;
/*
Аналог:
{
pencil: boolean;
fill: boolean;
pen: boolean;
}
*/
Мы также можем использовать отображаемые типы для переименования ключей объекта. Для этого нужно добавить немного дополнительного синтаксиса. Рассмотрим пример:
type EventGetters = {
// Разделение отображаемого типа на несколько строк
// достаточно необычно. Я делаю это только для улучшения читаемости кода
[
SensorName in
keyof HomeEvents
// Новый синтаксис
as `get${Capitalize<SensorName>}Event`
]: (eventId: string) => HomeEvents[SensorName]
}
/*
Превращается в:
{
getOpenEvent: (eventId: string) => Access;
getCloseEvent: (eventId: string) => Access;
...
getMoistureEvent: (eventId: string) => Motion;
}
Часть as
на "стороне ключа" позволяет преобразовать каждую разновидность HomeEvents
в новый тип. Это можно представить так:
// Используем только 3 значения для краткости
// `propertyNames` аналогичен `keyof HomeEvents`
const propertyNames = ["open", "close", "moisture"];
// `newPropertyNames` — это финальные ключи
// отображаемого типа.
const newPropertyNames = propertyNames.map(
// Эта функция выполняет роль `as`
(property) => `get${capitalize(property)}Event`
);
// Утилита `Capitalize` встроена в TypeScript,
// но для обычных строковых значений нам нужно реализовать ее вручную.
const capitalize = (s: string): string => {
return s[0].toLocaleUpperCase() + s.slice(1);
};
Краткий обзор модификаторов отображения
Я упомяну эту функцию вскользь, поскольку не встречал ее использования на практике. Отображаемые типы могут менять такие характеристики, как доступность свойства (например, readonly
) или его обязательность (например, optional
), через так называемые "модификаторы отображения" (mapping modifiers). Оставлю это как упражнение для читателя.
Если нам нужно изменить типы значений объекта, все, что нужно сделать — это написать соответствующее выражение.
Вот пример:
type WithAuditor<T> = T & {
auditorId: string;
auditedOn: Date;
};
// Реализации всех этих типов неважны.
// Будем считать, что они представляют собой сложные объектные типы.
type BusinessRecords = {
notesAnDisclosures: NotesAndDisclosures;
profitLoss: ProfitLoss;
retainedEarnings: RetainedEarnings;
};
type ApplyAudit<Type> = {
[Key in keyof Type]: WithAuditor<Type[Key]>;
}
type Audit = (records: BusinessRecords) => ApplyAudit<BusinessRecords>;
В заключение, хотя мы не будем подробно рассматривать условные типы (conditional types) в этой статье, отмечу, что объединение отображаемых типов с условными значительно расширяет их возможности. "Условный отображаемый тип" может быть рекурсивным для вложенных объектов (например, как DeepPartial
из статьи Сэма Роуза о вспомогательных типах) или использоваться для фильтрации ключей объекта в зависимости от типов значений, например:
type ModalProps = {
show: boolean;
greeting: string;
onClose: () => void;
onOpen: () => void;
};
// Это можно реализовать с помощью условного отображаемого типа
type ModalFunctions = FunctionsOnly<ModalProps>;
/*
{
onClose: () => void;
onOpen: () => void;
}
*/
❯ Заключение
Сегодня мы рассмотрели много важных концепций:
- Типы доступа по индексу
Оператор keyof
- Основы общих типов
Ограничения общих типов с помощью ключевого слова extends
- Отображаемые типы
Эти возможности — отдельные инструменты для построения моделей данных. С помощью грамотного выбора и сочетания этих инструментов можно создавать надежные, поддерживаемые и точные модели.
Помните, как и любая мощная конструкция в программировании, чрезмерное использование этих возможностей может привести к проблемам с качеством кода. Подходите к рефакторингу типов с осторожностью, и не бойтесь переписывать модель данных несколько раз, если она не совсем подходит. Гибкость TypeScript означает, что для реализации определенного набора типов часто существует несколько способов, каждый из которых имеет свои преимущества и недостатки.
Пока-пока, и помните: парсите, а не валидируйте!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩