15

Pattern Matching Custom Data Types in TypeScript

 5 years ago
source link: https://www.tuicool.com/articles/hit/jmuIvaa
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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, and our matcher object from. 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 theseis 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.

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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK