Module signature shadows module types

Hi all,

I played a bit with functors and ended up in a bit of an unexpected blocker. I had a problem with a type in module built by a functor.

Here is my naive implementation that somehow replicates my case:

let rawValue = %raw("{a: {name: 'test'}}")

module Schema = {
	module type T = {
        type t 
        let extract: 'a => t
    }
  	
    module Make = (Item: T) => {
        let return = (val): promise<Item.t> => Item.extract(val)->Js.Promise.resolve
    }
}

module Wrapper = {

    module Test: Schema.T = {
        type t = {name: string}
        @get external extract: 'a => t = "a"
    }

	module MyTest = Schema.Make(Test)
}

let item = Wrapper.MyTest.return(rawValue)

let _ = item -> Js.Promise2.then(async val => {
	Js.log(val.name)
})

This code gives me error:

[E] Line 27, column 12:

The record field name can't be found.
  
  If it's defined in another module or file, bring it into scope by:
  - Prefixing it with said module name: TheModule.name
  - Or specifying its type: let theValue: TheModule.theType = {name: VALUE}

Initially, I thought that maybe I was too eager and it wouldn’t work this way because the compiler cannot follow Test.t type.
While playing with it by accident I removed the signature from module Test

    module Test = {
        type t = {name: string}
        @get external extract: 'a => t = "a"
    }

and just it solved my problem.

To be honest, I thought I wouldn’t make it, and in the end, I found it super cool that the compiler can type inference the module signature by itself.
Still, I found it surprising that even if I gave a concrete signature to Test module it somehow saw it as some abstract Test.t.

Are module signatures when defined explicitly for a module shadow module’s types for the compiler?

This is one of the key benefits of the type system ReScript uses - you can have types that are opaque on the interface but totally normal types internally. It allows you to apply rules to creating the types, since the only valid way to create a value of type t is a method provided by the module.

With that Schema.T type applied, there is literally no way (other than %raw and similar hacks) to even know that the value is a record, much less get to the name property.

On a related note, if you comment out the error and look at the compiled JS you’ll probably see the extract function was not treated as an external. Because it was only an external on the module, the Schema.T interface describes it as a regular function.

2 Likes

Does it mean that after applying Schema.T Test.t is overwritten with opaque type?

Hi @mishaszu,

Your issue is that the type Schema.T.t is abstract. This is often used for “data-hiding” purposes but it also disallows the compiler of “looking inside” the actual implementation. Therefore you can’t access the record type later on. (in your current solution)

Edit: Sorry for the confusion: What I refer to as abstract type is the same concept @spyder refers to as opaque type. Which means I’m propably using incorrect nomenclature.

I’ll try to further explain myself:

Long explanation

Given the following module type and implementation:

module type T = {
        type t 
        let toString: t => string
    }

module M = {
	type t = {name: string}
  	let toString = t => t.name
}

Since the module type T is not explictly used in the given code, the compiler infers the signature of module M as follows:

module type M = {
	type t = {name: string}
  	let toString: t => string
}

Coincidentally, this is similar (!) to module type T. The notable difference is type t being abstract vs the record signature. This means, you can access anything inside of module M anywhere else in your code base.
e.g.

module A = {
  let exclamation: M.t => string = x => x.name ++ "!"
}

Note, that you can use the type M.t as well as the “implementation knowledge” of type t being a record with a single field called name.


Now take a look at the following example (using the module type now),
which will yield a compile-error The record field name can't be found. when we try to access a record field of type t:

module type T = {
  type t
  let toString: t => string
}

module M: T = {
  type t = {name: string}
  let toString = t => t.name
}

module A = {
  let exclamation: M.t => string = x => x.name ++ "!"
}

This is the exact issue you were facing (but without the complexity of a functor):
The line module M: T = ... explicitly defines the module M having a module singature of module type T. (How this module is seen from the outside.)
Therefore, you are preventing any other code, than the module M itself, to actually know the shape of type t. To the outside of the module, we just know, there is a type called t defined in the module M, but we can only use values of this type with functions, which use this type.
This is actually data hiding.

Note: In many scenarios, this behavior is desirable.


Now looking at module types from another perspective: They can also be used in roughly similar ways, like typeclasses (in e.g. Haskell) can be used to support generalization:
If you define an argument having module type T, you are saying “I can pass any module to this function, as long as it’s signature is a superset of module type T”. (meaning the actual implementation of the passed module could have a lot more types, functions and values defined, but you are only caring about those, mentioned in module type T.

Now see the following functor:

module type T = {
  type t
  let toString: t => string
}

module M = {
  type t = {name: string}
  let toString = t => t.name
}

module Exclamation = {
  module Make = (X: T) => {
    let exclamation: X.t => string = t => t->X.toString ++ "!!!"
  }
}

module Test1 = Exclamation.Make(M)
let test2 = Test1.exclamation({name: "test"})

The functor’s argument module X is required to have module type T, therefore, the functor cannot know the actual shape of the type X.t, but the type itself can still be used.
Since the signature of module M is infered, the shape of type M.t is publicly known and can be used to create a value being passed to the exclamation function.
Note: If you would explicitly define module M to have the signature of module type T, you couldn’t create (or use the shape of) a value of M.t. - Which is, what you experienced!

Short Solution

To give a answer after the rather long explanation:
Just remove your explicit signature definition for Wrapper.Test and it will compile successfully:

let rawValue = %raw("{a: {name: 'test'}}")

module Schema = {
	module type T = {
        type t 
        let extract: 'a => t
    }
  	
    module Make = (Item: T) => {
        let return = (val): promise<Item.t> => Item.extract(val)->Js.Promise.resolve
    }
}

module Wrapper = {

    module Test = { // <--- module type `: Schema.t` got removed here
        type t = {name: string}
        @get external extract: 'a => t = "a"
    }

	module MyTest = Schema.Make(Test)
}

let item = Wrapper.MyTest.return(rawValue)


let _ = item -> Js.Promise2.then(async val => {
	Js.log(val.name)
})

“Masterclass”

Some additional information, if you desire to take more time to play around with these concepts:
The above defined functor explicitly defines the argument’s module type, but the compiler will infer the the module type returned by the functor.
While this may work for you, it is possible to explicitly annotate the return type as well:

module Schema = {
  module type T = {
    type t
    let extract: 'a => t
  }

  module type Schema = {
    type t
    let return: 'a => promise<t>
  }

  module Make = (Item: T): Schema => {
    type t = Item.t
    let return = (val): promise<Item.t> => Item.extract(val)->Js.Promise.resolve
  }
}

Now we have a new (but similar) problem:
The functor’s returned module has an abstract type t, which may not be desired.

“(destructive) replace” to the rescue:

module Schema = {
  module type T = {
    type t
    let extract: 'a => t
  }

  module type Schema = {
    type t
    let return: 'a => promise<t>
  }

  module Make = (Item: T): (Schema with type t := Item.t) => { // <-- Note the extra with ... syntax for the return type
    let return = (val): promise<Item.t> => Item.extract(val)->Js.Promise.resolve
  }
}

In this case the module type Schema serves kind of as a “template” and we use “destructive replace” to remove the type definition Schema.t and replace any occurence within the module type with the type reference to Item.t.

Different Approaches

Depending on your use-case it is often easier to completely avoid the hassle of functors und use either First Class Modules or other architectural patterns.

3 Likes

Thank you for extensive explanation!

It explains a lot!
The whole subject is super interesting, and definitely I will experiment.
I almost never use functors on a daily basis, but I decided to write Relay BE with Rescript & Prisma with my own bindings for exercise.
Because of how Prisma client works it requires some “hacks” but I found it super cool to use functors with a schema to achieve something like a database model controller.

I did a similar backend with Rust, and I found it much nicer to write in Rescript (- deserialization, as it’s far easier to achieve in Rust thanks to macros & serde library). Using module signatures somehow reminds me of Rust traits but without the need to explicitly implement them :smile:

My primary mistake was an attempt to “implement” the module signature by assigning it to a module because I got used to it in other languages but meanwhile, the compiler does a great job deducing type by itself!

No, Test.t will always remain the same - but only within the Test module. When you apply the Schema.T interface to it, code outside of Test only sees Schema.Test.t which is opaque.

You are technically correct, they are abstract types, but the OCaml community tends to refer to the nature of an abstract type as opaque. Particularly to newcomers as it’s a word they’re more likely to be familiar with.

This brings me to an interesting point - there is a way to have the interface cake and eat it too.

module Schema = {
	module type T = {
        type t = private { name:string }
        let extract: 'a => t
    }
}

Adding private to a record type means the shape is exposed, allowing Js.log(val.name) to work, but nothing can create a record of that type. Doing this on the interface allows use of the shape while still ensuring Test is the only module that can create instances of the record.

5 Likes