Общие дискриминационные сужения союза


Это сообщение изначально было заметкой в моем цифровом саду 🌱.

При работе с дискриминированными союзами, также известными как тегированные союзы, в TypeScript часто приходится сужать значение союза до типа одного из его членов.

type Creditcard = {tag: 'Creditcard'; last4: string}

type PayPal = {tag: 'Paypal'; email: string}

type PaymentMethod = Creditcard | PayPal
Вход в полноэкранный режим Выход из полноэкранного режима

Учитывая приведенный выше тип, мы хотим либо проверить, что значение относится к типу PayPal, чтобы показать электронную почту пользователя, либо к типу Creditcard, чтобы показать последние 4 цифры.

Это можно сделать простой проверкой свойства discriminant, в данном случае tag. Поскольку каждый из двух членов типа PaymentMethod имеет разные типы для свойства tag, TypeScript способен, ну, дискриминировать их, чтобы сузить тип. Таким образом, имя союза tagged.

declare const getPaymentMethod: () => PaymentMethod

const x = getPaymentMethod()

if (x.tag === 'Paypal') {
  console.log("PayPal email:", x.email)
}

if (x.tag === 'Creditcard') {
  console.log("Creditcard last 4 digits:", x.last4)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Чтобы избежать такой явной проверки свойства tag, можно определить предикаты типа, или охранники.

const isPaypal = (x: PaymentMethod): x is PayPal =>
  x.tag === 'Paypal'

const x = getPaymentMethod()

if (isPayPal(x)) {
  console.log("PayPal email:", x.email)
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Проблема с такими предохранителями заключается в том, что они должны быть определены для каждого члена каждого дискриминируемого типа объединения в вашем приложении. Или нет?

Если вы похожи на меня, то, вероятно, задаетесь вопросом, как сделать это обобщенно для всех типов объединений. Может быть, это вообще возможно с помощью какой-то черной магии на уровне типов TypeScript?

Короткий ответ — да.

const isMemberOfType = <
  Tag extends string,
  Union extends {tag: Tag},
  T extends Union['tag'],
  U extends Union & {tag: T}
>(
  tag: T,
  a: Union
): a is U => a.tag === tag
Вход в полноэкранный режим Выход из полноэкранного режима

Правильный ответ — что за блядь черт возьми хак?

Хорошо, позвольте мне объяснить.

Нам нужны четыре общих типа, на практике вызывающей стороне не нужно будет передавать их, они здесь для того, чтобы TypeScript мог делать свой вывод типов.

Обратите внимание, что для всех них мы не разрешаем какой-либо тип, а используем extends, чтобы сообщить TypeScript, что эти общие типы должны удовлетворять определенным условиям.

  • А U — это конкретный член Union, который мы хотим сузить, он должен расширять Union и его тег должен быть T, конкретный, который нам нужен.

Tag и Union существуют для определения типа союза, от которого мы хотим сузиться, и T и U, за отсутствие за неимением лучших названий, являются тем местом, где происходит волшебство, поскольку это суженные типы, до которых мы ожидаем добраться.

const x = getPaymentMethod()

if (isMemberOfType('Creditcard', x)) {
  console.log('Creditcard last 4 digits:', x.last4)
}

if (isMemberOfType('Paypal', x)) {
  console.log('PayPal email:', x.email)
}
Вход в полноэкранный режим Выйдите из полноэкранного режима

И вуаля, это работает!

Вот так TypeScript заполняет дыры дженериков.

Есть вариация isMemberOfType, которую я люблю использовать, которая работает как своего рода геттер вместо предиката типа.

const getMemberOfType = <
  Tag extends string,
  Union extends {tag: Tag},
  T extends Union['tag'],
  U extends Union & {tag: T}
>(
  tag: T,
  a: Union
): U | undefined =>
  isMemberOfType<Tag, Union, T, U>(tag, a) ? a : undefined
Вход в полноэкранный режим Выход из полноэкранного режима

Использование аналогично, но, конечно, требует проверки на null.

const cc = getMemberOfType('Creditcard', getPaymentMethod())

if (cc) {
  console.log('Creditcard last 4 digits:', cc.last4)
}

const pp = getMemberOfType('Paypal', getPaymentMethod())

if (pp) {
  console.log('PayPal email:', pp.email)
}
Войти в полноэкранный режим Выход из полноэкранного режима

Есть небольшая проблема: возвращаемый тип не так хорош, поскольку он основан на нашем определении дженерика (U extends Union & {tag: T}).

На практике это не проблема, но раз уж мы зашли так далеко, то можем продолжать, верно?

Введите Extract, одну из утилит TypeScript для работы с типами:

Создает тип, извлекая из Type все члены объединения, которые можно присвоить Union.

Например, в следующем случае T0 будет иметь тип 'a':

type T0 = Extract<'a' | 'b' | 'c', 'a' | 'f'>
Вход в полноэкранный режим Выйти из полноэкранного режима

Определение простое:

// Extract from Type those types that are assignable to Union
type Extract<Type, Union> = Type extends Union ? Type : never
Войти в полноэкранный режим Выход из полноэкранного режима

С нашим текущим определением isMemberOfType и getMemberOfType, возвращаемый тип расширяет союз: U расширяет Union & {tag: T}.

В случае с PayPal это будет PayPal & { tag: 'PayPal' }. Добавив Extract к возвращаемому типу, мы можем получить вместо него PayPal.

const isMemberOfType = <
  Tag extends string,
  Union extends {tag: Tag},
  T extends Union['tag'],
  U extends Union & {tag: T}
>(
  tag: T,
  a: Union
): a is Extract<Union, U> => a.tag === tag

const getMemberOfType = <
  Tag extends string,
  Union extends {tag: Tag},
  T extends Union['tag'],
  U extends Union & {tag: T}
>(
  tag: T,
  a: Union
): Extract<Union, U> | undefined =>
  isMemberOfType<Tag, Union, T, U>(tag, a) ? a : undefined
Вход в полноэкранный режим Выход из полноэкранного режима

Так намного чище! Теперь я могу спать спокойно …

В заключение, мы перешли от простой дискриминантной проверки свойств к чудовищной конструкции из дженериков и вывода типов, которая достигает того же самого. Стоит ли использовать эти утилиты в производстве? Конечно! Тем более, если это запутает наших коллег. Может быть, и нет, но было интересно открыть для себя некоторые крутые вещи, которых мы можем достичь с помощью TypeScript.

Вот финальная версия примеров в TypeScript Playground.

Оцените статью
devanswers.ru
Добавить комментарий