One of widely used functional programming feature is pattern matching. Unfortunately there is no language support for it yet in TypeScript - that’s when libraries comes to rescue and this is my take on it.

TypeMatcher

TypeMatcher is a tiny javascript library designed to provide basic pattern matching constructs and in this post I’ll describe how it works and give few usage examples.

WARNING: Post updated for version 0.9.2, breaking changes may happen in version with minor updates until version 1.x.

Library consists of two main components:

Matchers

Matcher is a function which checks that input value matches representing type:

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

Some provided matchers are: isString, isNumber, isArrayOf, isTuple1. TypeMatcher type is compatible with lodash functions with same purpose so you can use them directly.

Matching DSL

Matching DSL consists of match, caseWhen and caseDefault functions, used to build case handlers and evaluate match expression over a given value.

match(value, cases) takes an input value, a case handler with type type MatchCase<A, R> = { map: (val: A) => R }, and returns first matching result or throws an error if none matched.

caseWhen(matcher, fn) is used to build MatchCase<A, R> instances, using TypeMatcher<T> and handler functions. To handle default case - use caseDefault() function or method.

Installation

npm install --save [email protected]

Examples

Match exact values

Ensure input matches defined enum values:

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)
)

Exhaustivity checking

Compiler (tsc) will succeed when cases do cover all possible input values:

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")
)

But when we remove one of the cases, ex:

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

compiler will fail:

error TS2345: Argument of type 'UserRole' is not assignable to parameter of type 'UserRole.Member | UserRole.Admin'.

   const roleStr: string = match(role(),
                                 ~~~~~~

Match object fields

Ensure input object has all fields defined by your custom type:

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({})

Match arrays

Ensure all array values match given type:

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

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

Match tuples

Library defines matchers for isTuple1 to isTuple10, this ought to be enough for anybody :).

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)), _ => _)
)

Custom matchers

You can provide you own matcher implementations compatible with TypeMatcher<T> type:

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, _ => _)
)

Pre-build matcher

Basically, match expression is evaluation of a function A => B, where A is our input value and B is an union type of cases result types, and we can build this function using matcher builder, which is also useful for highly performance-sensitive code and you want to save some CPU cycles:

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

const s: string = stringify([10])

Check source code for all defined matchers: https://github.com/lostintime/node-typematcher/blob/master/src/lib/matchers.ts.

Limitations

Case handlers type variance

Avoid explicitly setting argument type in caseWhen() handler function, let type inferred by compiler. You may set more specific type, but check will bring you more general one and compiler will not fail. This is caused by TypeScript Function Parameter Bivariance feature.

UPD: Typescript v2.6 brings --strictFunctionTypes compiler option and if it’s on, for this code:

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

you will now get this error:

error TS2345: Argument of type '8' is not assignable to parameter of type '10'.

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

Use caseDefault at the end

match will execute all cases as provided, so first matching will return, use caseDefault, caseAny last.

New match DSL introduced in [email protected] brought compile-time exhaustivity checking, so this code:

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

will fail at compile time with:

error TS2322: Type 'string' is not assignable to type '"ten" | "twenty"'.

  const x: "ten" | "twenty" = match(8 as any,
        ~

But you still have to handle default case when any result type is expected (which is highly NOT recommended), otherwise it may fail with No match error at runtime.

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