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

TypeMatcher

TypeMatcher - это крошечная библиотека javascript, разработанная для предоставления базовых конструкций сопоставления с образцом, и в этом посте я опишу, как это работает, и приведу несколько примеров использования.

ВНИМАНИЕ: сообщение обновлено для версии 0.9.2, критические изменения могут произойти в версии с незначительными обновлениями до версии 1.x.

Библиотека состоит из двух основных компонентов:

Матчеры

Matcher - это функция, которая проверяет соответствие входного значения типу:

type TypeMatcher<T> = (val: any) => val is T

Некоторые предоставленные сопоставители: isString, isNumber, isArrayOf, isTuple1. Тип TypeMatcher совместим с функциями lodash с той же целью, поэтому вы можете использовать их напрямую.

Соответствие DSL

Соответствующий DSL состоит из функций match, caseWhen и caseDefault, используемых для создания обработчиков случаев и оценки выражения соответствия по заданному значению.

match(value, cases) принимает входное значение, обработчик регистра с типом type MatchCase<A, R> = { map: (val: A) => R }, и возвращает первый совпадающий результат или выдает ошибку, если совпадений нет.

caseWhen(matcher, fn) используется для создания MatchCase<A, R> экземпляров с использованием TypeMatcher<T> и функций-обработчиков. Для обработки случая по умолчанию используйте caseDefault() функцию или метод.

Установка

npm install --save [email protected]

Примеры

Соответствие точным значениям

Убедитесь, что ввод соответствует определенным значениям перечисления:

enum UserRole {
  Member = 0,
  Moderator = 1,
  Admin = 2
}
const role: UserRole = match(20,
  caseWhen(isLiteral(UserRole.Member), _ => _).
  caseWhen(isLiteral(UserRole.Moderator), _ => _).
  caseWhen(isLiteral(UserRole.Admin), _ => _).
  caseDefault(() => UserRole.Member)
)

Проверка исчерпываемости

Компилятор (tsc) завершится успешно, если случаи покрывают все возможные входные значения:

enum UserRole {
  Member = 0,
  Moderator = 1,
  Admin = 2
}
function role(): UserRole {
  return UserRole.Member
}
const roleStr: string = match(role(),
  caseWhen(isLiteral(UserRole.Member), _ => "member").
  caseWhen(isLiteral(UserRole.Moderator), _ => "moderator").
  caseWhen(isLiteral(UserRole.Admin), _ => "admin")
)

Но когда мы удаляем один из случаев, например:

const roleStr: string = match(role(),
  caseWhen(isLiteral(UserRole.Member), _ => "member").
  caseWhen(isLiteral(UserRole.Admin), _ => "admin")
)

компилятор выйдет из строя:

error TS2345: Argument of type 'UserRole' is not assignable to parameter of type 'UserRole.Member | UserRole.Admin'.
   const roleStr: string = match(role(),
                                 ~~~~~~

Сопоставить поля объекта

Убедитесь, что в объекте ввода есть все поля, определенные вашим настраиваемым типом:

enum Gender {
  Male = 'M',
  Female = 'F'
}
/**
 * Our custom type for user profiles
 */
type User = {
  name: string,
  gender: Gender,
  age: number
  address?: string
}
/**
 * User type matcher object
 */
const isUser: TypeMatcher<User> = hasFields({
  name: isString,
  gender: isEither(isLiteral(Gender.Male), isLiteral(Gender.Female)),
  age: isNumber,
  address: isOptional(isString)
})
// now you can build a inline match expression
const user: User = match({},
  caseWhen(isUser, _ => _).
  caseDefault(() => { throw new Error("Invalid user object") })
)
// or define a decoder function then use it
// you can return type you need, like Either or Validation disjuctions
const User: (val: unknown) => User | null = matcher(
  caseWhen(isUser, _ => _).
  caseDefault(() => null)
)
const user2: User | null = User({})

Сопоставить массивы

Убедитесь, что все значения массива соответствуют заданному типу:

const someNumbers: Array<number> = match([],
  caseWhen(isArrayOf(isNumber), _ => _).
  caseDefault(() => [])
)
const arr: Array<string> = match(someNumbers,
  caseWhen(isArrayOf(isNumber), arr => arr.map(it => it.toString()))
)

Сопоставить кортежи

Библиотека определяет сопоставители от isTuple1 до isTuple10, этого должно хватить для всех :).

const t1: [number] = match([10],
  caseWhen(isTuple1(isNumber), _ => _)
)
const t2: [number, string] = match(t1 as any,
  caseWhen(
    isTuple3(isFiniteNumber, isString, isBoolean),
    (t): [number, string] => [t[0], t[1]]
    // sometimes you will have to help type inference
  )
)
const t4: [string, number, boolean, 10] = match(t2 as any,
  caseWhen(isTuple4(isString, isNumber, isBoolean, isLiteral(10)), _ => _)
)

Пользовательские сопоставители

Вы можете предоставить свои собственные реализации сопоставления, совместимые с типом TypeMatcher<T>:

import * as _ from "lodash"
function isValidGender(val: any): val is "M" | "F" {
  return val === "M" || val === "F"
}
const s: string = match(10 as any,
  caseWhen(isValidGender, g => `gender: ${g}`)
    .caseWhen(_.isArray, arr => arr.join(","))
    .caseWhen(_.isString, _ => _)
)

Предварительная сборка сопоставителя

По сути, выражение match - это оценка функции A => B, где A - наше входное значение, а B - тип объединения типов результатов наблюдений, и мы можем построить эту функцию, используя конструктор matcher, который также является полезно для кода, чувствительного к производительности, и вы хотите сэкономить несколько циклов процессора:

const stringify = matcher(
  caseWhen(isArrayOf(isNumber), _ => _.join(",")).
  caseWhen(isArrayOf(isBoolean), _ => _.map(b => (b ? "yes" : "no")).join(",")).
  caseWhen(isString, _ => _)
)
const s: string = stringify([10])

Проверьте исходный код для всех определенных сопоставителей: https://github.com/lostintime/node-typematcher/blob/master/src/lib/matchers.ts.

Ограничения

Дисперсия типов обработчиков дел

Избегайте явной установки типа аргумента в функции обработчика caseWhen(), пусть тип определяется компилятором. Вы можете установить более конкретный тип, но проверка даст вам более общий тип, и компилятор не выйдет из строя. Это вызвано функцией TypeScript Двувариантность параметров функции.

UPD: в Typescript v2.6 есть опция компилятора --strictFunctionTypes, и если она включена, для этого кода:

match(8, caseWhen(isNumber, (n: 10) => "n is 10"))

теперь вы получите эту ошибку:

error TS2345: Argument of type '8' is not assignable to parameter of type '10'.
  match(8, caseWhen(isNumber, (n: 10) => "n is 10"))
        ~

УСТАРЕЛО: используйте caseDefault в конце

Новый match DSL, представленный в [email protected], обеспечивает проверку полноты во время компиляции, поэтому этот код:

const x: "ten" | "twenty" = match(8 as any, 
  caseWhen(isString, () => "ten")
)

потерпит неудачу во время компиляции с:

error TS2322: Type 'string' is not assignable to type '"ten" | "twenty"'.
  const x: "ten" | "twenty" = match(8 as any,
        ~

Но вам все равно придется обрабатывать случай по умолчанию, когда ожидается тип результата any (что настоятельно НЕ рекомендуется), в противном случае он может выйти из строя с ошибкой No match во время выполнения.

const x: any = match(8 as any, 
  caseWhen(isString, () => "ten").
  caseDefault(() => "twenty")
)

Ссылки

Первоначально размещено здесь: https://lostintimedev.com/2017/09/06/typematcher-pattern-matching-library-for-typescript.html