Variant with generic "mysterious" behaviour

Hello, dear community,

I’ve stumbled upon an interesting behaviour of the variant type. Here’s an example:

module Post = {
  type t = {userId: int}

  let decode = (_t: Js.Json.t): result<t, string> => {
    Ok({userId: 1}) // some decoding is happening here...
  }
}

module Http = {
  type expect<'t> =
    | ExpectJson(Js.Json.t => result<'t, string>)
    | ExpectText(string => result<string, string>)

  // imagine it takes some url as well...
  let get = async (expect: expect<'t>): result<'t, string> => {
    switch expect {
    | ExpectJson(dec) => dec(Js.Json.Null)
    | ExpectText(val) => val("")
    }
  }
}

Http.get(Http.ExpectJson(Post.decode)) // compiler is sceptical here...
---

This has type: Js.Json.t => result<Post.t, string>
  But it's expected to have type: Js.Json.t => result<string, string>
  
  The incompatible parts:
    Post.t vs string

Can someone explain why this is the case? The types are explicit… when handling a JSON response we need a decoder, when handling a text response, the function validates the string… So, why type mismatch?

Thank you in advance!

1 Like

hmm indeed the error is not obvious at first sight.

Polymorphic type annotations don’t work the way you think they do in Hindley-Milner type systems, you can for example do this:

let foo : 'a = 1

It’s obvious here that foo is of type int and not of type 'a, but at the same time int is indeed a possible type for 'a so it’s not wrong per se, so the type system doesn’t complain. If you want the type system to complain about such issues, you’d have to add an interface to your file / module:

module Http: {
    type expect<'t> =
    | ExpectJson(Js.Json.t => result<'t, string>)
    | ExpectText(string => result<string, string>)
  let get : expect<'t> => promise<result<'t, string>>
} = {
  type expect<'t> =
    | ExpectJson(Js.Json.t => result<'t, string>)
    | ExpectText(string => result<string, string>)

  // imagine it takes some url as well...
  let get = async (expect: expect<'t>): result<'t, string> => {
    switch expect {
    | ExpectJson(dec) => dec(Js.Json.Null)
    | ExpectText(val) => val("")
    }
  }
}

Here it would point to the implementation of Http and rightfully complain that:

Signature mismatch:
  ...
  Values do not match:
    let get: expect<string> => promise<result<string, string>>
  is not included in
    let get: expect<'t> => promise<result<'t, string>>
  playground.res:13:3-53: Expected declaration
  playground.res:20:7-9: Actual declaration

Indeed if you pay attention at your code, val("") is of type result<string, string> so during type checking (what is called “unification”), the type checker will infer that get return type is also result<string, string> because all branches of a switch need to return the same type.

So if get expects a function of type Js.Json => result<string, string>, it can’t accept a function that returns result<Post.t, string>.

Does it make sense?

Now how would you solve your situation?

You’d have two solutions, either you make your variants return result of string in both cases, but this doesn’t seem to be a solution here or you can supercharge your variant and make it a GADT (generalized algebraic data type), which allows you to make your function return different types depending on the case of the variant it receives. It’s pretty powerful but it can also be cumbersome to use because you have to help the type checker in this case:

module Post = {
  type t = {userId: int}

  let decode = (_t: Js.Json.t): result<t, string> => {
    Ok({userId: 1}) // some decoding is happening here...
  }
}

module Http = {
  type rec expect<_> =
    | ExpectJson(Js.Json.t => result<'t, string>): expect<'t>
    | ExpectText(string => result<string, string>): expect<string>

  // imagine it takes some url as well...
  let get:
    type t. expect<t> => promise<result<t, string>> =
    async expect => {
      switch expect {
      | ExpectJson(dec) => dec(Js.Json.Null)
      | ExpectText(val) => val("")
      }
    }
}

module Str = {
  type t = string
  let decode = s => Ok(s)
}

let post: promise<result<Post.t, string>> = Http.get(
  Http.ExpectJson(Post.decode),
)
let string: promise<result<string, string>> = Http.get(
  Http.ExpectText(Str.decode),
)

Playground link

3 Likes

@tsnobip you’ve connected the dots with these two:

  • let foo : 'a = 1 and…
  • all branches of a switch need to return the same type.

Thank you for pointing this out!

Can I say that I “trigger” the unification by handling the value of type expect<'t> within the Http.get function? This is because the compiler can infer the types by values and because there are two branches (expectJson and expectText), one of which takes a generic 't, the compiler “narrows” it down to the first most concrete type, which is string, isn’t it?

If I add another variant, say ExpectInt(int => result<int, string>) and handle it in my switch like so ExpectInt(val) => val(42) // decoding an int, then the compiler complains about this line with int vs string.

1 Like

@tsnobip GATD worked amazing and I think the solution looks pretty elegant, actually. Could you recommend a place to learn more about GATDs in Rescript (or OCaml)?

1 Like

Yes, I think you have a pretty good understanding of the issue now! :slight_smile:

1 Like

Unfortunately there’s no good resource about GADTs in rescript that I know of, but there are quite a few of them in ocaml, the syntax is different but the language is extremely close, you can take a look at this tutorial for example
https://raphael-proust.gitlab.io/code/my-first-gadt.html

There is also Sketch.sh - Interactive ReasonML/OCaml sketchbook which is in Reason syntax so very close to ReScript.

4 Likes

@fham this is an amazing page! Thank you for sharing!