DEV Community

loading...

Pattern Matching Custom Data Types in Typescript

alexsasharegan profile image Alex Regan Originally published at blog.parametricstudios.com on ・9 min read

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.

Discussion (0)

pic
Editor guide