Pattern matching with opaque signature types

I understand the recommended approach is to use signature files and hide the actual data types. Is this correct?

The benefit here is that you can change your representation of the data, maybe for performance reasons, and all your consumers still work.

First, won’t that make it not possible to do pattern matching? In F# they have a concept of “ActivePatterns” to solve for this. It is also useful to define additional ways to deconstruct the data.

Second this makes it super cumbersome to get at fields in records. Everything must be done through additional functions you define. Every record field might need an additional function to extract it.

Are people really using ReScript this way - defining signature files for every module AND hiding the representation of their data? Am I wrong about the pattern matching impact?

You are correct about the benefits to hiding internal structures, and also correct about the drawbacks. It’s not a one-size-fits-all recommendation.

You want to hide the implementation if its details wouldn’t be useful to your consumers or if you need special control over it. One example is the built-in Map structure. Internally, it uses AVL trees defined as nested record types. Being able to access these records directly wouldn’t do the user any good, since the data is meant to be used with functions (e.g. Map.get). Also, the AVL structure only works as long as the keys are sorted. The Map module’s functions guarantee that they are sorted correctly. If a consumer could directly manipulate the structure themselves, then they could break it.

However, if you’re using data that users do need to directly access, then there’s not necessarily a benefit of hiding it. If you end up writing an accessor function for every record field, then it’s probably easier to just expose the fields directly.

They are when they’re working with a module that benefits from it :slightly_smiling_face:. Not every project, or every module in every project, does it. But it’s an extremely useful tool for modules that need it.

One extra thing to note:

It is possible to have it both ways, to an extent. The private keyword will expose the implementation to pattern-matching but will not allow consumers to create the types:

module OddOrEven: {
  type t = private Odd(int) | Even(int)
  let make: int => t
} = {
  type t = Odd(int) | Even(int)
  let make = x =>
    if mod(x, 2) == 0 {
      Even(x)
    } else {
      Odd(x)
    }
}
// Pattern matching works:
let f = x =>
  switch x {
  | OddOrEven.Odd(x) => Js.log2(x, "is odd")
  | Even(x) => Js.log2(x, "is even")
  }
// You can construct values with the module's functions:
let good = OddOrEven.make(2)
// You cannot construct values directly:
let bad: OddOrEven.t = Odd(2)
// ERROR: Cannot create values of the private type OddOrEven.t

6 Likes

John answered it perfectly, I just want to add one additional note to address this:

There’s even a convenient way to get the compiler to do it for you:

@deriving(accessors)
type person = {id: int, name: string}

This generates the functions id and name which get the corresponding fields from record objects.

4 Likes