Using first class modules to test in ReasonML


#1

Hi everyone :slightly_smiling_face:,

I recently started using ReasonML, and have been trying to make use of the first class modules pattern (described here) to make my ReasonML program testable. My program uses a module for Axios, which I purify first using a Functor. I would like my program to easily switch implementations for the Axios module for testing purposes. So far, this is what I came up with (I boiled it down to pseudo-code):

// Axios.re
module type S = {
	type t; // type of the instance
	let create: () => t

	let get: (t, string) => Js.Promise.t(string)
}

module RealImpl: S = {
	type t;
	[@bs.module "axios"] external create: unit => t = "create";
	let get: (t, string) => Js.Promise.resolve("A real value")
}

module FakeImpl: S = {
	type t = unit;
	let get: (t, string) => Js.Promise.resolve("A fake value")
}

// AxiosIO.re
// Purifies the API of Axios
module Make = (A: Axios.S) => {
	let get = (url, axiosInstance: A.t): IO.t(string, Js.Promise.error) =>
		IO.toIOLazy(() => axiosInstance->A.get(url))
}

// Main.re
let makeIOProgram(module A: Axios.S) => {
	module AIO = AxiosIO.Make(A);
	let axiosInstance = A.create()

	axiosInstance |> AIO.get("foo") // Works!
}

But what if I want to do something in a function, using the AxiosIO interface and axiosInstance? For example, to define a client. To achieve that, I first defined an interface:

// AxiosIO.re
module type S = {
  type t
  let get: (string, t) => IO.t(string, Js.Promise.error);
};

and let AxiosIO adhere to that interface:

// AxiosIO.re
module Make = (A: ADTooling.AxiosS): S => {
  type t = A.t
  ...
}

Then, I would like to program against this interface

// Client.re
let make = (module A: AxiosIO.S, axiosInstance: A.t) => {
	let getBusinessValue = axiosInstance |> A.get("business entity")
	getBusinessValue
}

Note that I really want to pass the axiosInstance to this make function, as I want to setup logging etc. for it first. Unfortunately, this yields the error

This pattern matches values of type
A.t
but a pattern was expected which matches values of type
’a
The type constructor A.t would escape its scope

Using parametrized types:

// Client.re
let make = (module A: Interface, axiosInstance: 'a) => {
	let getBusinessValue = axiosInstance |> A.get("business entity")
	getBusinessValue
}

yields the same error:

This has type:
A.t =>
IO.t(string, Js.Promise.error)
But somewhere wanted:
'a => 'd
The type constructor A.t would escape its scope

From here I have no clue how to proceed, as my knowledge of ocaml is very limited :disappointed_relieved:. How would you approach this problem?


#2

You need to connect the type inside the first-class module with the type of the instance value. There is two way to do that. First, you can keep the information about the concrete type of t in the packed module available outside of the packed module with a with constraint

let make =
  ( type instance_type,
    module A: AxiosIO.S with type t = instance_type,
    axiosInstance: instance_type
  ) => {
	let getBusinessValue = axiosInstance |> A.get("business entity");
	getBusinessValue
}

This can be made a bit shorter with

type axiosIO('a) = (module AxiosIO.S with type t = 'a)
let make =
  ( type instance_type,
    (module A): axiosIO.S(instance_type),
    axiosInstance: instance_type
  ) => {
     let getBusinessValue = axiosInstance |> A.get("business entity");
      getBusinessValue
}

This method works if we can reflect the type corresponding to S.t in the outside type system. Otherwise, if we don’'t want to keep this information in the type system, we need a way to remember which first-class module is associated to which value. This is a work for GADTs:

type axios = InterfaceAndValue(axiosIO('a),'a): axios

Here, the type definition is stating that a value of type axios is a pair composed of a first-class module of type axios('a) and a value of this type 'a for some type 'a that we don’t and cannot remember.
With this encoding, your function becomes:

let make = (InterfaceAndValue((module A), axiosInstance)) => {
  let getBusinessValue = axiosInstance |> A.get("business entity");
  getBusinessValue
}

Another way to see BoundValue is that is an explicit formed of an interface for a type t and a value of this type t. (There is a related construction which puts the value inside the first-class module rather than outside).


#3

Thanks! This is a very insightful reply :slight_smile: . I managed to get my example working with the first solution you provided, https://sketch.sh/s/NGDLZqcAP65hf64QUXluaa/ .
Initially it didn’t work, as I was creating the axiosInstance through A.create, instead of AIO.create, so I had to change the definition of the interface S of AxiosIO to include also a create.

I am still looking at / studying your other provided solution using GADT as well. I haven’t touched upon GADT’s in ReasonML yet, but are there any considerations in choosing one solution over the other?


#4

If the first solution works in your case, it is probably the simplest solution. The second solution adds a level of complexity to make it possible to store pair of interface and value in the same container. Imagine for instance that we have three different interfaces and associated values:

module A: AxiosIO.S= ...
let a: A.t
module B: AxiosIO.S= ...
let b: B.t;
module C: AxiosIO.S= ...
let c: C.t

In this case, I can apply make to each pair separately.

[ make ((module A), a), make((module B), b), make((module C), c) ]

which seems natural, but it looks like I could factorize the make outside of the list.
But if I try

List.map(((intf,v)) => make(intf,v)), 
[ ((module A), a), 
  ((module B), b), 
  ((module C), c)
]

it doesn’t work because the type of each pair is slightly different. Using a GADTs here allow us to forget this difference;

List.map((InterfanceAndValue(intf,v)) => make(intf,v)),
  [ InterfaceAndValue((module A), a),
    InterfaceAndValue((module B), b),
    Interface,AndValue((module C), c)
  ]

and it is fine because the type that we forget with the GADT constructor doesn’t matter once make has been applied.