Pattern Matching Custom Data Types in Typescript
Our application data comes in all shapes and sizes. We choose the best data structures for our problem, but when we need to put a variety of our data through the same pipeline, we have to manage safely distinguishing our data types and handling all their possible variations.
In this post, I want to share a pattern I’ve discovered for working with different data types in a homogenous way. You can think of it as a functional programming version of the adapter pattern. I’ll introduce the concept of pattern matching, generic enums, and look at how we can leverage the power of TypeScript to mimic these patterns.
The code examples here are hosted in a more fully functioning version at github.com/alexsasharegan/colorjs.
What Is Pattern Matching?
At its simplest, it’s a control flow structure that allows you to match type/value patterns against a value. Pattern matching is usually a feature of functional programming languages like Haskell, Elixir, Elm, Reason, and more. Some languages, like Rust, offer pattern matching while not fitting neatly into the functional category. Consider this Rust code:
let x = 1;
match x {
1 => println!("One"),
2 | 3 => println!("Two or Three"),
4 .. 10 => println!("Four through Ten"),
_ => println!("I match everything else"),
}
Here the match
expression evaluates x
against the patterns inside the block
and then executes the corresponding match arm’s expression/block. Patterns are
evaluated and matched in top-down order. The first pattern is the literal value
1
; the second matches either a 2
or a 3
; the third describes a range
pattern from 4
to 10
; the last pattern is a curious bit of syntax shared by
many pattern matching systems that denotes a “catch-all” pattern that matches
anything.
Most pattern matching systems also come with compile-time exhaustiveness checking. The compiler checks all the match expressions and evaluates them to guarantee that one of the arms will be matched. If the compiler cannot guarantee the match expression is exhaustive, the program will fail to compile. Some compilers can also lint your match expressions to warn when a match arm shadows a subsequent match arm because it is more general than a later, more specific arm. Exhaustiveness checks are a really powerful tool to avoid bugs in our code.
Pattern Matching With Enums
Enums exist in various languages, but apart from enumerating a named set, Rust’s enum members are capable of holding values. Here’s an example from the rust book:
enum IpAddr {
V4(u8, u8, u8, u8),
V6(String),
}
let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));
The IpAddr
enum type has two members: V4
and V6
. Note that each can hold
different types. This is a really powerful way to model real-world problems.
Different cases often come with different data modeling concerns. An IPv4 can be
easily modeled with a tuple of 4 bytes (u8
), while an IPv6 has many valid
expressions that a string covers. Combining enums with values affords us a
type-safe way to model each case with the optimal data structure.
Applying Pattern Matching In TypeScript
Now let’s see if we can implement some Rust-like enum and pattern matching capabilities. For our example, we’re going to implement a color enum capable of matching against three distinct color types–RGB, HSL, and Hex. We’ll start by stubbing out some classes. We could also use plain JavaScript objects, but we’ll use classes for a little extra convenience.
class RGBColor {
constructor(public r: number, public g: number, public b: number) {}
}
class HSLColor {
constructor(public h: number, public s: number, public l: number) {}
}
class HexColor {
constructor(public hex: string) {}
}
If you’re unfamiliar with the empty-looking constructor syntax,
it’s a shorthand to initialize properties.
By declaring an access modifier (public
, protected
, private
), typescript
automatically generates the code to assign the name of the constructor argument
as a class property. It might feel a little like magic (which I normally
avoid), but it’s such a common pattern that I prefer to let the TS compiler
reliably do the work for me.
Step 1: Enum
To start, we need to represent all our possible states. In our case, this will be the three color types. TypeScript has an enum type, but it does not hold generic values like in Rust. TS enums create a set of named constants of numbers or strings. We’ll want to use string enums since they will play nicer later on when used as object keys.
enum ColorFormat {
Hex = "hex",
RGB = "rbg",
HSL = "hsl",
}
Since enum
is not available in JS, I think it’s worth looking at how
TypeScript implements enum
.
var ColorFormat;
(function(ColorFormat) {
ColorFormat["Hex"] = "hex";
ColorFormat["RGB"] = "rbg";
ColorFormat["HSL"] = "hsl";
})(ColorFormat || (ColorFormat = {}));
The TS compiler constructs a lookup table from our enum keys to their values using a plain JavaScript object. In the case of numeric enums, it also constructs a reverse lookup from values to keys in the same object. JS objects can only have strings, numbers (which are cast to strings), and symbols as keys, but TS only supports using string or number enums.
Step 2: Discriminated Unions
While the name sounds complex, the concept is straightforward. It’s essentially
a set of different object shapes that, while different, all share a common key.
This key, the discriminant, enables TypeScript to distinguish between the
object types because its value is a literal value. A literal value would be
something like "foo"
instead of string
, or 1
instead of number
.
Here’s an example taken from Marius Schulz’s TypeScript blog (which you should definitely check out):
interface Cash {
kind: "cash";
}
interface PayPal {
kind: "paypal";
email: string;
}
interface CreditCard {
kind: "credit";
cardNumber: string;
securityCode: string;
}
// PaymentMethod is the discriminated union of Cash, PayPal, and CreditCard
type PaymentMethod = Cash | PayPal | CreditCard;
Each interface has a unique shape, but all share the key kind
. Notice the
literal string values like "credit"
used in the discriminant. When you receive
the type PaymentMethod
, TypeScript will not let you access any of the unique
properties until you “discriminate” which shape it is by asserting the kind
is
"cash"
, "paypal"
, or "credit"
.
We can use POJO’s (Plain Old
JavaScript Objects) for our object types, or we can use classes. I’m going to
use classes, but we’re going to look at examples of both. Using classes will
allow us to implement a more ergonomic match later on. We need to use the
readonly
modifier on our discriminant key so the TS compiler knows the
format
value won’t change.
// previous code omitted for brevity
class RGBColor {
readonly format = ColorFormat.RGB;
}
class HSLColor {
readonly format = ColorFormat.HSL;
}
class HexColor {
readonly format = ColorFormat.Hex;
}
type Color = HexColor | RGBColor | HSLColor;
Now we’ve defined our union of possible colors. We used the classes as types here for brevity, but defining the colors as interfaces (which our classes satisfy) would make our union more flexible.
Step 3: Matcher Objects
Next we’re going to create an object that behaves like our rust pattern match. The keys of our object will correspond with the values of our enum–this is why we needed to use string enums. Our values can’t be expressions because in JavaScript they will be evaluated immediately. To ensure lazy evaluation only when a condition is matched, we need the values to be functions.
type ColorMatcher<Out> = {
[ColorFormat.Hex]: (color: HexColor) => Out;
[ColorFormat.HSL]: (color: HSLColor) => Out;
[ColorFormat.RGB]: (color: RGBColor) => Out;
};
The ColorMatcher
object requires us to pass an object with all the keys
present. This forces our code to handle all the possible states. Its also
generic over a single return value type. This can feel limiting at first, but in
practice reduces code complexity and bugs.
Step 4: Match Implementation
Since we don’t have real match semantics in JavaScript, we can make use of a
switch
statement. Our function will need to accept as arguments: our Color
union type with the discriminant from step 2,
and our matcher object from step 3. We’ll switch on
our color’s discriminant property, and once the discriminant is matched by a
case, TypeScript can infer the sub-type of Color
to be Hex
, HSL
, or RGB
.
The compiler will also make sure we invoke the correct matcher function based on
the inferred type. The implementation looks like this:
function matchColor<Out>(color: Color, matcher: ColorMatcher<Out>): Out {
switch (color.format) {
default:
return expectNever(color);
case ColorFormat.Hex:
return matcher[ColorFormat.Hex](color);
case ColorFormat.HSL:
return matcher[ColorFormat.HSL](color);
case ColorFormat.RGB:
return matcher[ColorFormat.RGB](color);
}
}
It’s worth mentioning that when the Color
type is defined as a union of
interfaces, the argument value can be either a POJO or a class instance–both
types would satisfy the expected properties. Also note the helper func,
expectNever
. The function leverages the never
type in a clever way to give
us compile-time exhaustiveness checking. In short, the never
type is how
TypeScript expresses an impossible state. If you’d like to know more, Marius
Schulz has another great
blog post going
deeper into the subject.
function expectNever(_: never, message = "Non exhaustive match."): never {
throw new Error(message);
}
Since we opted to use classes, we can improve upon the matchColor
function by
implementing a match
method each color class in our Color
union. Since the
method is defined on the class, our match method implementation will only need
the ColorMatcher
argument–a nice bonus for ergonomics. It can then call the
correct the match “arm” with itself.
class RGBColor {
match<Out>(matcher: ColorMatcher<Out>): Out {
return matcher[ColorFormat.RGB](this);
}
}
class HSLColor {
match<Out>(matcher: ColorMatcher<Out>): Out {
return matcher[ColorFormat.HSL](this);
}
}
class HexColor {
match<Out>(matcher: ColorMatcher<Out>): Out {
return matcher[ColorFormat.Hex](this);
}
}
Step 5: Usage
We’ve got all the pieces in place to finally start making using our color
classes. We can start adding/refactoring functions to accept a Color
. This
will allow callers to use whatever is most convenient to them–HSL, RGB, or Hex.
We could use our Color
as a prop in components:
interface Props {
color: Color;
}
function BgColor(props: Props) {
let backgroundColor = props.color.match({
hsl: color => `hsl(${color.h}, ${color.s}%, ${color.l}%)`,
rgb: color => `rgb(${color.r}, ${color.g}, ${color.b})`,
hex: color => `#${color.hex}`,
});
return <div style={{backgroundColor}}>{{props.children}}</div>
}
We could also implement some color conversion helpers and create some sass-like helpers like this:
function lighten(color: Color, percent: number): HSL {
let hsl = color.match({
// Already our preferred type, so just return it.
hsl: c => c,
rgb: c => convertRGBToHSL(c),
hex: c => convertHexToHSL(c),
});
hsl.l = Math.min(100, hsl.l + percent);
return hsl;
}
function darken(color: Color, percent: number): HSL {
let hsl = color.match({
hsl: c => c,
rgb: c => convertRGBToHSL(c),
hex: c => convertHexToHSL(c),
});
hsl.l = Math.max(0, hsl.l - percent);
return hsl;
}
The HSL format is the easiest to lighten or darken, so these functions just
convert to HSL, and then raise/lower the lightness. Notice that while the
argument we accept is of type Color
, we return an HSL
instance. We could
have chosen to return Color
.
A best practice when using these
tagged unions is to accept the widest
(practical) range of types, but return the most exact type. If we returned
Color
to our callers, they would need to match yet again to determine that we
returned an HSL color–something we already knew.
Pros & Cons
Matching custom data types with this pattern can be really powerful. Our code clearly documents all the possible states in our enums–a benefit for newcomers to our code as well as ourselves down the road. Our matcher objects also force us to acknowledge every possible state whenever we interact with our data. If we’re in a hurry to implement a feature, the compiler is there to make sure we didn’t forget anything. The matcher object’s consistent return type will help us avoid bugs. It’s hard to explain just how important this is, but in my experience, it’s changed a lot about the way I code and reduced a lot of needless bugs.
All the benefits don’t come without a cost though. This pattern is quite verbose and requires a lot of boilerplate. We are mimicking syntax that doesn’t exist in JavaScript, so the verbosity is an understandable trade-off, but one you’ll have to consider when deciding whether or not your situation will be improved by using this pattern. As a library author, I find this to be a solid foundation on which to build my custom data types. I push the verbosity down to the library level so I can provide convenience and clarity to the library consumer.
For further reading on this subject, you can check out a similar post by another community member, Manual Alabor: https://pattern-matching-with-typescript.alabor.me/.
In my next couple of posts (TBD), I’m going explain some more functional programming-inspired patterns that leverage this matching pattern under the hood to provide an abstraction and a framework for handling nullable types, robust error handling, and creating robust, chainable task pipelines.