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.

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 and caseWhen functions, used to compose matchers with case handlers, and few aliases to make code more readable: caseAny, caseDefault, caseId and caseThrow.

match(...cases) takes a variable list of type MatchCase<T> = (val: any) => CaseResult<T> arguments and returns first matching result or throws an error if none matched.

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

Installation

npm install --save typematcher

Examples

Match exact values

Ensure input value matches defined enum:

enum UserRole {
  Member = 0,
  Moderator = 1,
  Admin = 2
}

const role: UserRole = match(20)(
  caseWhen(isValue(UserRole.Member))(v => v),
  caseId(isValue(UserRole.Moderator)), // caseId is caseWhen with identity function
  caseId(isValue(UserRole.Admin)),
  caseDefault(() => UserRole.Member)
);

Match object fields

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

enum Gender {
  Male = 'M',
  Female = 'F'
}

type User = {
  name: string,
  gender: Gender,
  age: number
  address?: string
}

const user: User = match({})(
  caseId(
    hasFields({
      name: isString,
      gender: isEither(isValue(Gender.Male), isValue(Gender.Female)),
      age: isNumber,
      address: isOptional(isString)
    })
  ),
  caseThrow(new Error("Invalid user object"))
);

Match arrays

Ensure all array values match given type:

const someNumbers: Array<number> = match([])(
  caseId(isArrayOf(isNumber)),
  caseDefault(() => [])
);

const arr: Array<string> = match([10])(
  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])(
  caseId(isTuple1(isNumber))
);

const t2: [number, string] = match(false)(
  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(false)(
  caseId(isTuple4(isString, isNumber, isBoolean, isValue<10>(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)(
  caseWhen(isValidGender)(g => `gender: ${g}`),
  caseWhen(_.isArray)(arr => arr.join(','))
  caseId(_.isString),
);

Pre-build matcher

When you have highly performance-sensitive code you may pre-build matcher and save some CPU cycles:

const matchString = matchWith(
  caseWhen(isArrayOf(isNumber))(arr => arr.join(',')),
  caseId(isString)
);

for(let i = 0; i < 100000000; i++) {
  const s: string = matchString([i]);
}

Check source code for all defined matchers: https://github.com/lostintime/node-typematcher/blob/master/src/lib/index.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.

// this will compile :(
match(8)(caseWhen(isNumber)((n: 10) => "n is 10");

vs

match(8)(caseWhen(isNumber)(n => {
  const x: 10 = n; // this will not compile: Type 'number' is not assignable to type '10'
  return "n is 10"
}));

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 '(n: 10) => string' is not assignable to parameter of type '(v: number) => string'.
  Types of parameters 'n' and 'v' are incompatible.
    Type 'number' is not assignable to type '10'.

Use caseDefault at the end

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