Pattern matching for TypeScript
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")
)
Links
- TypeMatcher source code
- TypeMatcher npm package
- Pattern Matching Support - TypeScript language support proposal
- ECMAScript Pattern Matching Syntax - ES language support proposal
- Support some non-structural (nominal) type matching in TypeScript