TypeScript. Продвинутые типы
Привет, Хаброжители! Мы сдали в типографию очередную новинку
«Профессиональный TypeScript. Разработка масштабируемых JavaScript-приложений». В этой книге программисты, которые уже знакомы с JavaScript на среднем уровне, узнают, как освоить TypeScript. Вы поймете, как TypeScript поможет масштабировать код в 10 раз лучше и снова сделать программирование увлекательным.
Вашему вниманию представлен отрывок одной главы из книги «Продвинутые типы».
Продвинутые типы
Система типов TypeScript, признанная во всем мире, своими возможностями удивляет даже Haskell-программистов. Как вы уже знаете, она не только выразительна, но и легка в использовании: ограничения типов и связи в ней кратки, понятны и в большинстве случаев выводятся автоматически.
Моделирование таких элементов динамического JavaScript, как прототипы, привязанные this, перегрузки функций и вечно меняющиеся объекты, требует настолько богатой системы типов и их операторов, которую даже Бэтмен взял бы на вооружение.
Я начну эту главу с глубокого погружения в темы подтипов, совместимости, вариантности, случайной величины и расширения. Затем более детально раскрою особенности проверки типов на основе потока команд, включая уточнение и тотальность. Далее продемонстрирую некоторые продвинутые особенности программирования на уровне типов: подключение и отображение типов объектов, использование условных типов, определение защит типов и запасные решения вроде утверждений типов и утверждений явного присваивания. В заключение я познакомлю вас с продвинутыми паттернами для повышения безопасности типов: паттерном объект-компаньон, улучшением интерфейса для кортежей, имитированием номинальных типов и безопасным расширением прототипа.
Связи между типами
Рассмотрим связи в TypeScript подробнее.
Подтипы и супертипы
Мы уже затрагивали совместимость в разделе «О типах» на с. 34, поэтому сразу углубимся в эту тему, начиная с определения подтипа.
Вернитесь к рис. 3.1 и увидите встроенные в TypeScript связи подтипов.
- Массив является подтипом объекта.
- Кортеж является подтипом массива.
- Все является подтипом any.
- never является подтипом всего.
- Класс Bird, расширяющий класс Animal, — это подтип класса Animal.
Согласно определению, которое я только что дал для подтипа, это значит, что:
- Везде, где нужен объект, можно использовать массив.
- Везде, где нужен массив, можно использовать кортеж.
- Везде, где нужен any, можно использовать объект.
- Везде можно использовать never.
- Везде, где нужен Animal, можно использовать Bird.
Супертип — это противоположность подтипа.
СУПЕРТИПЕсли у вас есть два типа, A и B, и при этом B является супертипом A, то вы можете безопасно использовать A везде, где требуется B (рис. 6.2).
И снова исходя из схемы на рис. 3.1:
- Массив является супертипом кортежа.
- Объект является супертипом массива.
- Any является супертипом всего.
- Never не является чьим-либо супертипом.
- Animal — это супертип Bird.
Это просто противоположный подтипам принцип и ничего более.
Вариантность
Для большинства типов достаточно легко понять, является ли некий тип A подтипом B. Для простых типов вроде number, string и др. можно обратиться к схеме на рис. 3.1 или самостоятельно определить, что number, содержащийся в объединении number | string, является подтипом этого объединения.
Но есть более сложные типы, например обобщенные. Подумайте над такими вопросами:
- Когда Array является подтипом Array<В>?
- Когда форма A является подтипом формы B?
- Когда функция (a: A) => B является подтипом функции (c: C) => D?
Правила подтипизации для типов, содержащих другие типы (то есть имеющих параметры типа вроде Array, формы с полями вроде {a: number} или функции вроде (a: A) => B), осмысливать уже сложнее, потому что они не согласованы в разных языках программирования.
Чтобы облегчить чтение последующих правил, я представлю несколько элементов синтаксиса, который не работает в TypeScript (не беспокойтесь, он не математический):
- A <: B означает, что «A является подтипом того же, что и тип B»;
- A >: B означает, что «A является супертипом того же, что и тип B».
Вариантность формы и массива
Понять, почему языки не согласуются в правилах подтипизации сложных типов, поможет пример с формой, которая описывает пользователя в приложении. Представим ее посредством пары типов:
// Существующий пользователь, переданный с сервера.
type ExistingUser = {
id: number
name: string
}
// Новый пользователь, еще не сохраненный на сервере.
type NewUser = {
name: string
}
Предположим, что стажер в вашей компании получил задание написать код для удаления пользователя. Начинает он со следующего:
function deleteUser(user: {id?: number, name: string}) {
delete user.id
}
let existingUser: ExistingUser = {
id: 123456,
name: 'Ima User'
}
deleteUser(existingUser)
deleteUser получает объект типа {id?: number, name: string} и передает в него existingUser типа {id: number, name: string}. Обратите внимание, что тип свойства id (number) — это подтип ожидаемого типа (number | undefined). Следовательно, весь объект {id: number, name: string} — это подтип {id?: number, name: string}, поэтому TypeScript это допускает.
Видите ли вы какие-либо проблемы с безопасностью? Есть одна: после передачи ExistingUser в deleteUser TypeScript не знает, что id пользователя был удален, поэтому если вы прочитаете existingUser.id после его удаления deleteUser (existingUser), то TypeScript по-прежнему будет считать, что existingUser.id имеет тип number.
Очевидно, что использование типа объекта там, где ожидается его супертип, небезопасно. Так почему же TypeScript это допускает? Суть в том, что он не задумывался как абсолютно безопасный. Его система типов стремится перехватывать реальные ошибки и делать их наглядными для программистов любого уровня. Поскольку деструктивные обновления (вроде удаления свойства) относительно редки на практике, TypeScript расслаблен и позволяет вам присвоить объект там, где ожидается его супертип.
А что насчет противоположного случая: можно ли присвоить объект там, где ожидается его подтип?
Добавим новый тип для старого пользователя, а затем удалим пользователя с этим типом (представьте, что вы добавляете типы в код, который написал ваш коллега):
type LegacyUser = {
id?: number | string
name: string
}
let legacyUser: LegacyUser = {
id: '793331',
name: 'Xin Yang'
}
deleteUser(legacyUser) // Ошибка TS2345: aргумент типа 'LegacyUser'
// несовместим с параметром типа
// '{id?: number |undefined, name: string}'.
// Тип 'string' несовместим с типом 'number |
// undefined'.
Когда вы передаете форму со свойством, чей тип является супертипом ожидаемого типа, TypeScript ругается. Все потому, что id — это string | number | undefined, а deleteUser обрабатывает только тот случай, где id является number | undefined.
Ожидая форму, вы можете передать тип с типами свойств, которые <: ожидаемых типов, но не можете передать форму без типов свойств, являющихся супертипами их ожидаемых типов. Когда речь идет о типах, мы говорим: «TypeScript-формы (объекты и классы) являются ковариантными в типах их свойств». То есть, чтобы объект A мог быть присвоен объекту B, каждое его свойство должно быть <: соответствующего ему свойства в B.
Ковариантность — это один из четырех видов вариантности:
Инвариантность
Нужен конкретно T.
Ковариантность
Нужен <:T.
Контрвариантность
Нужен >: T.
Бивариантность
Устроит либо <:T, либо >: T.
В TypeScript каждый сложный тип является ковариантным в своих членах — объектах, классах, массивах и возвращаемых типах функций, за одним исключением: типы параметров функций контрвариантны.
Не все языки применяют такое же конструктивное решение. В одних объекты являются инвариантными в типах свойств, потому что ковариантные типы свойств могут вести к небезопасному поведению. Другие языки имеют разные правила для изменяемых и неизменяемых объектов (попробуйте порассуждать об этом самостоятельно). Третьи, вроде Scala, Kotlin и Flow, даже имеют явный синтаксис, позволяющий программистам определять вариантность типов данных.Создатели TypeScript предпочли баланс: инвариантность объектов в типах свойств повышает безопасность, но усложняет использование системы типов, поскольку заставляет вас запрещать то, что на деле безопасно (например, если не удалять id в deleteUser, передача объекта, являющегося супертипом ожидаемого типа, все равно будет безопасной).
Вариантность функции
Рассмотрим несколько примеров.
Функция A является подтипом функции B, если A имеет такую же или меньшую арность (число параметров), чем B, и:
- Тип this, принадлежащий A, либо не определен, либо >: типа this, принадлежащего B.
- Каждый из параметров A >: соответствующего параметра в B.
- Возвращаемый тип A <: возвращаемого типа B.
Обратите внимание, что для того, чтобы функция A могла быть подтипом функции B, ее тип this и параметры должны быть >: встречных частей в B, в то время как ее возвращаемый тип должен быть
Начнем с определения трех типов (вместо class можно использовать другие типы, где A: < B <: C):
class Animal {}
class Bird extends Animal {
chirp() {}
}
class Crow extends Bird {
caw() {}
}
Итак, Crow
Определим функцию, получающую Bird и заставляющую ее чирикать:
function chirp(bird: Bird): Bird {
bird.chirp()
return bird
}
Пока все хорошо. Что TypeScript позволяет вам передать в chirp?
chirp(new Animal) // Ошибка TS2345: аргумент типа 'Animal'
chirp(new Bird) // несовместим с параметром типа 'Bird'.
chirp(new Crow)
Экземпляр Bird (как параметр chirp типа bird) или экземпляр Crow (как подтип Bird). Передача подтипа работает, как и ожидалось.
Создадим новую функцию. На этот раз ее параметр будет функцией:
function clone(f: (b: Bird) => Bird): void {
// ...
}
clone требуется функция f, получающая Bird и возвращающая Bird. Какие типы функций можно передать для f безопасно? Очевидно, функцию, получающую и возвращающую Bird:
function birdToBird(b: Bird): Bird {
// ...
}
clone(birdToBird) // OK
Что насчет функции, получающей Bird, но возвращающей Crow или Animal?
function birdToCrow(d: Bird): Crow {
// ...
}
clone(birdToCrow) // OK
function birdToAnimal(d: Bird): Animal {
// ...
}
clone(birdToAnimal) // Ошибка TS2345: аргумент типа '(d: Bird) =>
// Animal' несовместим с параметром типа
// '(b: Bird) => Bird'.Тип 'Animal'
// несовместим с типом 'Bird'.
birdToCrow работает, как и ожидалось, но birdToAnimal выдает ошибку. Почему? Представьте, что реализация clone выглядит так:
function clone(f: (b: Bird) => Bird): void {
let parent = new Bird
let babyBird = f(parent)
babyBird.chirp()
}
Передав функции clone функцию f, возвращающую Animal, мы не сможем вызвать в ней .chirp. Поэтому TypeScript должен убедиться, что переданная нами функция возвращает как минимум Bird.
Когда мы говорим, что функции ковариантны в их возвращаемых типах, это значит, что функция может быть подтипом другой функции, только если ее возвращаемый тип <: возвращаемого типа той функции.
Хорошо, а что насчет типов параметров?
function animalToBird(a: Animal): Bird {
// ...
}
clone(animalToBird) // OK
function crowToBird(c: Crow): Bird {
// ...
}
clone(crowToBird) // Ошибка TS2345: аргумент типа '(c: Crow) =>
// Bird' несовместим с параметром типа '
// (b: Bird) => Bird'.
Чтобы функция была совместима с другой функцией, все ее типы параметров (включая this) должны быть >: соответствующих им параметров в другой функции. Чтобы понять почему, подумайте о том, как пользователь мог бы реализовать crowToBird, прежде чем передавать ее в clone?
function crowToBird(c: Crow): Bird {
c.caw()
return new Bird
}
TSC-ФЛАГ: STRICTFUNCTIONTYPESИз-за наследования функции в TypeScript по умолчанию ковариантны в своих параметрах и типах this. Чтобы использовать более безопасное контрвариантное поведение, которое мы только что изучили, нужно активировать флаг {«strictFunctionTypes»: true} в tsconfig.json.
Если вы уже используете {«strict»: true}, то ничего дополнительно делать не нужно.
Теперь, если clone вызовет crowToBird с new Bird, мы получим исключение, поскольку .caw определен во всех Crow, но не во всех Bird.
Это означает, что функции контрвариантны в их параметрах и типах this. То есть функция может быть подтипом другой функции, только если каждый из ее параметров и тип this будут >: соответствующих им параметров в другой функции.
К счастью, эти правила не нужно заучивать. Просто вспомните о них, когда редактор выдаст красное подчеркивание при передаче вами некорректно типизированной функции куда-либо.
Совместимость
Взаимосвязи подтипов и супертипов являются ключевой концепцией любого статически типизированного языка. Они также важны для понимания того, как работает совместимость (напомню, что совместимость относится к правилам TypeScript, определяющим возможность использования типа A там, где требуется тип B).
Когда TypeScript требуется ответить на вопрос: «Совместим ли тип A с типом B?», он следует простым правилам. Для не enum-типов — вроде массивов, логических типов, чисел, объектов, функций, классов, экземпляров классов и строк, включая типы литералов, — A совместим с B, если верно одно из условий.
- A <: B.
- A является any.
Правило 1 — это просто определение подтипа: если A является подтипом B, тогда везде, где нужен B, можно использовать A.
Правило 2 — это исключение из правила 1 для удобства взаимодействия с кодом JavaScript.
Для типов перечислений, созданных ключевыми словами enum или const enum, тип A совместим с перечислением B, если верно одно из условий.
- A является членом перечисления B.
- B имеет хотя бы один член типа number, а A является number.
Правило 1 в точности такое же, как и для простых типов (если A является членом перечисления B, тогда A имеет тип B и мы говорим, что B
Правило 2 необходимо для удобства работы с перечислениями, которые серьезно подрывают безопасность в TypeScript (см. подраздел «Enum» на с. 60), и я рекомендую их избегать.
Расширение типов
Расширение типов — это ключ к пониманию работы системы вывода типов. TypeScript снисходителен при выполнении и скорее допустит ошибку при выводе более общего типа, чем при выводе максимально конкретного. Это упростит вам жизнь и сократит временные затраты на борьбу с замечаниями модуля проверки типов.
В главе 3 вы уже видели несколько примеров расширения типов. Рассмотрим другие.
Когда вы объявляете переменную как изменяемую (с let или var), ее тип расширяется от типа значения ее литерала до базового типа, к которому литерал принадлежит:
let a = 'x' // string
let b = 3 // number
var c = true // boolean
const d = {x: 3} // {x: number}
enum E {X, Y, Z}
let e = E.X // E
Это не касается неизменяемых деклараций:
const a = 'x' // 'x'
const b = 3 // 3
const c = true // true
enum E {X, Y, Z}
const e = E.X // E.X
Можно использовать явную аннотацию типа, чтобы не допустить его расширения:
let a: 'x' = 'x' // 'x'
let b: 3 = 3 // 3
var c: true = true // true
const d: {x: 3} = {x: 3} // {x: 3}
Когда вы повторно присваиваете нерасширенный тип с помощью let или var, TypeScript расширяет его за вас. Чтобы это предотвратить, добавьте явную аннотацию типа в оригинальную декларацию:
const a = 'x' // 'x'
let b = a // string
const c: 'x' = 'x' // 'x'
let d = c // 'x'
Переменные, инициализированные как null или undefined, расширяются до any:
let a = null // any
a = 3 // any
a = 'b' // any
Но, когда переменная, инициализированная как null или undefined, покидает область, в которой была объявлена, TypeScript присваивает ей определенный тип:
function x() {
let a = null // any
a = 3 // any
a = 'b' // any
return a
}
x() // string
Тип const
Тип const помогает отказаться от расширения декларации типа. Используйте его как утверждение типа (см. подраздел «Утверждения типов» на с. 185):
let a = {x: 3} // {x: number}
let b: {x: 3} // {x: 3}
let c = {x: 3} as const // {readonly x: 3}
const исключает расширение типа и рекурсивно отмечает его члены как readonly даже в глубоко вложенных структурах данных:
let d = [1, {x: 2}] // (number | {x: number})[]
let e = [1, {x: 2}] as const // readonly [1, {readonly x: 2}]
Используйте as const, когда хотите, чтобы TypeScript вывел максимально узкий тип.
Проверка лишних свойств
Расширение типов также фигурирует, когда TypeScript проверяет, является ли один тип объекта совместимым с другим типом объекта.
Типы объектов ковариантны в их членах (см. подраздел «Вариантность формы и массива» на с. 148). Но, если TypeScript будет следовать этому правилу без дополнительных проверок, могут возникнуть проблемы.
Например, рассмотрите объект Options, который можно передать в класс для его настройки:
type Options = {
baseURL: string
cacheSize?: number
tier?: 'prod' | 'dev'
}
class API {
constructor(private options: Options) {}
}
new API({
baseURL: 'https://api.mysite.com',
tier: 'prod'
})
Что произойдет теперь, если вы допустите ошибку в опции?
new API({
baseURL: 'https://api.mysite.com',
tierr: 'prod' // Ошибка TS2345: аргумент типа '{tierr: string}'
}) // несовместим с параметром типа 'Options'.
// Объектный литерал может определять только
// известные свойства, но 'tierr' не существует
// в типе 'Options'. Вы хотели написать 'tier'?
Это распространенный баг при работе в JavaScript, и хорошо, что TypeScript помогает его перехватить. Но если типы объектов ковариантны в их членах, как TypeScript его перехватывает?
Иначе говоря:
- Мы ожидали тип {baseURL: string, cacheSize?: number, tier?: 'prod' | 'dev'}.
- Мы передали тип {baseURL: string, tierr: string}.
- Переданный тип — это подтип ожидаемого типа, но TypeScript знал, что надо сообщить об ошибке.
Благодаря проверке лишних свойств, когда вы пытаетесь присвоить новый тип объектного литерала T другому типу, U, а в T есть свойства, которых нет в U, TypeScript сообщает об ошибке.
Новый тип объектного литерала — это тип, который TypeScript вывел из объектного литерала. Если этот объектный литерал использует утверждение типа (см. подраздел «Утверждения типов» на с. 185) или присвоен переменной, тогда новый тип расширяется до регулярного типа объекта и его новизна пропадает.
Попробуем сделать это определение более емким:
type Options = {
baseURL: string
cacheSize?: number
tier?: 'prod' | 'dev'
}
class API {
constructor(private options: Options) {}
}
new API({ ❶
baseURL: 'https://api.mysite.com',
tier: 'prod'
})
new API({ ❷
baseURL: 'https://api.mysite.com',
badTier: 'prod' // Ошибка TS2345: аргумент типа '{baseURL:
}) // string; badTier: string}' несовместим
// с параметром типа 'Options'.
new API({ ❸
baseURL: 'https://api.mysite.com',
badTier: 'prod'
} as Options)
let badOptions = { ❹
baseURL: 'https://api.mysite.com',
badTier: 'prod'
}
new API(badOptions)
let options: Options = { ❺
baseURL: 'https://api.mysite.com',
badTier: 'prod' // Ошибка TS2322: тип '{baseURL: string;
} // badTier: string}'несовместим с типом
// 'Options'.
new API(options)
❶ Инстанцинируем API с baseURL и одно из двух опциональных свойств: tier. Все работает.
❷ Ошибочно прописываем tier как badTier. Объект опций, который мы передаем в new API, — новый (его тип выведен, он несовместим с переменной, и мы не делаем для него утверждения типа), поэтому при проверке лишних свойств TypeScript обнаруживает лишнее свойство badTier (которое определено в объекте опций, но не в типе Options).
❸ Делаем утверждение, что неверный объект опций имеет тип Options. TypeScript больше не рассматривает его как новый и делает заключение из проверки лишних свойств, что ошибок нет. Синтаксис as T описан в подразделе «Утверждения типов» на с. 185.
❹ Присваиваем объект опций к переменной badOptions. TypeScript больше не воспринимает его как новый и, произведя проверку лишних свойств, делает заключение, что ошибок нет.
❺ Когда мы явно типизируем options как Options, объект, присваиваемый нами options, является новым, поэтому TypeScript выполняет проверку лишних свойств и находит баг. Заметьте, что в этом случае проверка лишних свойств не производится, когда мы передаем options в new API, но она происходит, когда мы пытаемся присвоить объект опций к переменной options.
Эти правила не нужно заучивать. Это лишь внутренняя эвристика TypeScript для перехвата как можно большего числа багов. Просто помните о них, если вдруг станет интересно, откуда TypeScript узнал про баг, который даже Иван — старожил вашей компании и по совместительству профессиональный цензор кода — не заметил.
Уточнение
TypeScript производит символическое выполнение вывода типов. Модуль проверки типов использует инструкции потока команд (вроде if, ?, || и switch) наряду с запросами типов (вроде typeof, instanceof и in), тем самым уточняя типы по ходу чтения кода, как это делал бы программист. Однако эта удобная особенность поддерживается весьма небольшим количеством языков.
Представьте, что вы разработали в TypeScript API для определения правил CSS и ваш коллега хочет использовать его, чтобы установить HTML-элемент width. Он передает ширину, которую вы хотите позднее разобрать и сверить.
Сначала реализуем функцию для разбора строки CSS в значение и единицу измерения:
// Мы используем объединение строчных литералов для описания
// возможных значений, которые может иметь единица измерения CSS
type Unit = 'cm' | 'px' | '%'
// Перечисление единиц измерения
let units: Unit[] = ['cm', 'px', '%']
// Проверить каждую ед. изм. и вернуть null, если не будет совпадений
function parseUnit(value: string): Unit | null {
for (let i = 0; i < units.length; i++) {
if (value.endsWith(units[i])) {
return units[i]
}
}
return null
}
Затем используем parseUnit, чтобы разобрать значение ширины, переданное пользователем. width может быт числом (возможно, в пикселах) или строкой с прикрепленными единицами измерения, или null, или undefined.
В этом примере мы несколько раз прибегаем к уточнению типа:
type Width = {
unit: Unit,
value: number
}
function parseWidth(width: number | string | null |
undefined): Width | null {
// Если width — null или undefined, вернуть заранее.
if (width == null) { ❶
return null
}
// Если width — number, предустановить пикселы.
if (typeof width === 'number') { ❷
return {unit: 'px', value: width}
}
// Попытка получить единицы измерения из width.
let unit = parseUnit(width)
if (unit) { ❸
return {unit, value: parseFloat(width)}
}
// В противном случае вернуть null.
return null ❹
}
❶ TypeScript способен понять, что свободная проверка равенства на соответствие null в JavaScript вернет true и для null, и для undefined. Он также знает, что если проверка пройдет, то мы сделаем возврат, а если мы не делаем возврат, значит, проверка не прошла и с этого момента тип width — это number | string (он больше не может быть null или undefined). Мы говорим, что тип был уточнен из number | string | null | undefined в number | string.
❷ Проверка typeof запрашивает значение при выполнении, чтобы увидеть его тип. TypeScript также пользуется преимуществом typeof во время компиляции: в ветви if, где проверка проходит, TypeScript знает, что width — это number. В противном случае (если эта ветка делает return) width должна быть string — единственным оставшимся типом.
❸ Поскольку parseUnit может вернуть null, мы проверяем это. TypeScript знает, что, если unit верна, тогда она должна иметь тип Unit в ветке if. В противном случае unit неверна, что значит — ее тип null (уточненный из Unit | null).
❹ В завершение мы возвращаем null. Это может случиться, только если пользователь передаст string для width, но эта строка будет содержать неподдерживаемые единицы измерения.
Я проговорил ход мыслей TypeScript в отношении каждого произведенного уточнения типа. TypeScript проделывает огромную работу, учитывая ваши рассуждения в процессе чтения и написания кода и кристаллизуя их в проверку типов и порядок их вывода.
Типы размеченного объединения
Как мы только что выяснили, TypeScript хорошо понимает принципы работы JavaScript и способен отслеживать наше уточнение типов, словно читая мысли.
Допустим, мы создаем систему пользовательских событий для приложения. Начинаем с определения типов событий наряду с функциями, обрабатывающими поступление этих событий. Представьте, что UserTextEvent моделирует событие клавиатуры (например, пользователь напечатал текст ), а UserMouseEvent моделирует событие мыши (пользователь сдвинул мышь в координаты [100, 200]):
type UserTextEvent = {value: string}
type UserMouseEvent = {value: [number, number]}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value // string
// ...
return
}
event.value // [number, number]
}
TypeScript знает, что внутри блока if event.value должен быть string (благодаря проверке typeof), то есть event.value после блока if должно быть кортежем [number, number] (из-за return в блоке if).
К чему приведет усложнение? Добавим уточнения к типам событий:
type UserTextEvent = {value: string, target: HTMLInputElement}
type UserMouseEvent = {value: [number, number], target: HTMLElement}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value // string
event.target // HTMLInputElement | HTMLElement (!!!)
// ...
return
}
event.value // [number, number]
event.target // HTMLInputElement | HTMLElement (!!!)
}
Хотя уточнение и сработало для event.value, этого не случилось для event.target. Почему? Когда handle получает параметр типа UserEvent, это не значит, что нужно передать ему либо UserTextEvent, либо UserMouseEvent, — на деле можно передать аргумент типа UserMouseEvent | UserTextEvent. И поскольку члены объединения могут перекрываться, TypeScript требуется более надежный способ узнать, когда и какой случай объединения актуален.
Сделать это можно с помощью типов литералов и определения тега для каждого случая типа объединения. Хороший тег:
- В каждом случае располагается на одном и том же месте типа объединения. Подразумевает то же поле объекта, если речь идет об объединении типов объектов, или тот же индекс, если дело касается объединения кортежей. На практике размеченные объединения чаще являются объектами.
- Типизирован как тип литерала (строчный литерал, численный, логический и т. д.). Можно смешивать и сопоставлять различные типы литералов, но лучше придерживаться единственного типа. Как правило, это тип строчного литерала.
- Не универсален. Теги не должны получать аргументы обобщенных типов.
- Взаимоисключающий (уникален внутри типа объединения).
Обновим типы событий с учетом вышесказанного:
type UserTextEvent = {type: 'TextEvent', value: string,
target: HTMLInputElement}
type UserMouseEvent = {type: 'MouseEvent', value: [number, number],
target: HTMLElement}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (event.type === 'TextEvent') {
event.value // string
event.target // HTMLInputElement
// ...
return
}
event.value // [number, number]
event.target // HTMLElement
}
Теперь, когда мы уточняем event на основе значения его размеченного поля (event.type), TypeScript знает, что в ветке if event должен быть UserTextEvent, а после ветки if он должен быть UserMouseEvent, Поскольку теги уникальны в каждом типе объединения, TypeScript знает, что они являются взаимоисключающими.
Используйте размеченные объединения при написании функции, обрабатывающей различные случаи типа объединения. К примеру, при работе с действиями Flux, восстановлениями в Redux или с useReducer в React.
Более подробно с книгой можно ознакомиться и оформить предзаказ по специальной цене на сайте издательства