Matching on JS Union members with Row Types (Handling JS Unions cont.)

The other day, I wrote about handling JS Unions with Row Types, by using a constructorless data type with a row parameter to note what labels I want to associate members with, with guard functions for how you would unsafely guard on these types. The unsafeGuardMember function described in that post gave us a way to eliminate members of the union until we are ultimately left with no more tracked union members (which very well happens in many uses of JS Unions).

This time, I'll continue with just a small extension to implement a match function.

Short review

Before we start, let's review the definitions of JSUnion and UnsafeGuardFor.

data JSUnion (members :: # Type)

Purposefully, the JSUnion cannot be constructed directly, but the members parameter being of kind # Type gives us a way to work with the possible values inside the union.

newtype UnsafeGuardFor (name :: Symbol) ty =
  UnsafeGuardFor (Foreign -> Boolean)

For a given field in our JSUnion, it should be feasible to implement a guard function that explicitly declares what type is to be extracted, using a test of Foreign -> Boolean.

class MatchMembers

With this information, we can define a class where we can iterate the members row type using RowToList. We can define this in terms of the RowList being iterated, members of the JS Union, a record of pairs that will contain the guard and an associated function to be applied, and a result type for the result of the functions being applied in the matching. And since we know that we may not have enough information to exhaustively check for the JS Union members (as is the case with any dynamically typed "outside world" value), the class method should return a Maybe result, where the Nothing will be returned if nothing has been found.

Put into a class definition, we end up with this:

class MatchMembers (xs :: RowList) (members :: # Type) (pairs :: # Type) result
  | xs result -> members pairs
    matchMembers :: RLProxy xs -> { | pairs } -> JSUnion members -> Maybe result

Where the fundep xs result -> members pairs exists primarily because row types can't be used in instance heads for matching. Otherwise, everything else is in order here.

I should probably mention here that maybe you actually are sure that you have exhaustively listed the possible members of your JS Union. In that case, you may want to use a coerced fromJust function, but it's your fault if it breaks.

Then our instances put concretely what we have described above. Starting with Nil:

instance matchMembersNil :: MatchMembers Nil members pairs result where
  matchMembers _ _ _ = Nothing


As with most RowList classes, our Cons instance contains the meat of what we want to accomplish. The one decision that needs to be made here is what kind of concrete type we want the pairs to have. I chose to go with the easy choice of using the Tuple type. From there, the rest of the instance constraints fall in line, where from Cons name ty tail, name is declared as a known Symbol, members is declared to have name ty as a field, and pairs is declared to have a tuple of the guard and the result function:

instance matchMembersCons ::
  ( IsSymbol name
  , RowCons name ty members' members
  , RowCons name (Tuple (UnsafeGuardFor name ty) (ty -> result)) pairs' pairs
  , MatchMembers tail members pairs result
  ) => MatchMembers (Cons name ty tail) members pairs result where

And as usual, the tail is then used to continue the instances through the list. The method body then has nothing more than application of the unsafeGuardMember function defined previously:

  matchMembers _ pairs union =
    case unsafeGuardMember unsafeGuard union of
      Right x -> Just $ fn x
      Left _ -> matchMembers (RLProxy :: RLProxy tail) pairs union
      nameP = SProxy :: SProxy name
      Tuple unsafeGuard fn = Record.get nameP pairs

And so using the RowCons constraint for pairs from above, we grab the Tuple unsafeGuard fn from { | pairs }.


Then all this needs is a nice top-level function to be called with, so we define it almost verbatim minus the RLProxy argument that we can infer the type for:

  :: forall members xs pairs result
   . RowToList members xs
  => MatchMembers xs members pairs result
  => { | pairs }
  -> JSUnion members
  -> Maybe result
matchJSUnion =
  matchMembers (RLProxy :: RLProxy xs)

And that's it!


First, let's see a usage that matches on a valid specified JS Union member.

    T.test "matchMembers 1" do
        (union :: TestUnion) = H.fromMember countP 1
        match = H.matchJSUnion
          { count: Tuple countGuard show
          , name: Tuple nameGuard id
      case match of
        Just value -> do
          Assert.equal value "1"
        Nothing ->
          T.failure "incorrect result from matchJSUnion"

Cool, so this works as expected, where the count guard correctly matches and we get Just "1"" as a result of show on the integer 1.

Next, let's see a typical case where some value doesn't match any of the cases we're handling.

    T.test "matchMembers 2" do
        (union :: TestUnion) = unsafeCoerce { crap: "some bullshit from JS" }
        match = H.matchJSUnion
          { count: Tuple countGuard show
          , name: Tuple nameGuard id
      case match of
        Just value -> do
          T.failure "incorrect result from matchJSUnion"
        Nothing ->

And so, we correctly get Nothing as a result of not matching on anything here. And yes, while this example seems readily obvious, people forget about cases when "exhaustive" checks are not actually exhaustive, so this actually is quite important to have.

Otherwise, if you are truly working with a closed set of members, then you could use fromJust here.


Hopefully this has shown that with a little bit of normal RowToList usage, you can provide yourself more convenient and correct ways of modeling problems and working with them.

If you're at all interested in using this library also, ping me sometime and I'll be more active about publishing and maintaining it.