Typechecker loses the plot with module?

Hi Folks.
Have hit this situation where my Gql Fragment types are not propagating through a type chain:
https://rescript-lang.org/try?code=LYewJgrgNgpgBAMQE4EMDmcC8cDeAoOOAFwE8AHeCAZ3RgBkBLYBorXAwuAOxAGEkYYFo2ZEAXHABGIEFA4BfPItIU4AYwFCiAHgDkGgHxsA2gGIACrSoAKfUgA0UmVACUcAD5xTAJRhqQSGA2di4AuniwrEQgAJJcAGYgbGpYBhxUAO4sagAW6uyEnhZW1hqOAPpumEYaHEW+-oE2GlU1SAp4ETCsOShUAHJ8msJMLGylw0RxiRIagizayOgAdNS0IiwGreqT0yAAtAbRe4elUKnqUMs8-PNEG0RV2ERIEPAceKCQsHAAQhBEaJcNj4QgAAQEKDURGW-mAZBAXBgXCIHEicGAKAA1vBsNYAH7+CAoiQMFHbUGcdFEJgwKhsTLZPJElEFTieACMFwARIi1DBuRxCnAAEw8ohZfmCzgebgXX4wKAwuIw6IAZReZLQ1i4bgA1Hq4NziLSqNLOIoZejgFQMNhubwoAw1FiMfBjQaTcA6Z0ZdpJACgUYcDa0IdfFCYVRNVw0PI4NoAPQBwGItKERSKIA

I understand this would happen with Module type opacity but thats not the case here?

[Looking directly at Mr “I don’t need type annotations” @yawaramin]

Thanks
Alex

I thank that problem with (cl => ...)

let hasNoCreditLimit = (creditInfo: credit<Frag.usageLimit>) => {
  let cl = creditInfo->toInfo
  cl.noCreditLimit == true
}

It should works.

right, sure. other valid Rescript compiles… but this doesn’t!

What about:

let hasNoCreditLimit = (creditInfo) => creditInfo->toInfo->((cl: Frag.usageLimit) => cl.noCreditLimit) == true 

You don’t need a type annotation, but sometimes you need a module prefix to help the compiler find the record field:

let hasNoCreditLimit = creditInfo =>
  creditInfo->toInfo->(cl => cl.Frag.noCreditLimit)

Note, x == true is always the same as x.

Also, piping into a lambda literal feels a little un-ergonomic to me. Imho it’s better to define a named function and use that. I would put it in the Frag module, as it makes sense to put accessor functions in the same module as the data type they work on.

Actually, ReScript already provides a convenience to create the accessor for you:

module Frag = {
  @deriving(accessors)
  type usageLimit = {
    noCreditLimit: bool
  }
}

And now you can do:

let hasNoCreditLimit = creditInfo =>
  creditInfo->toInfo->Frag.noCreditLimit

EDIT: the compiler error basically tells you how to fix it:

[E] Line 13, column 94:
The record field noCreditLimit 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.noCreditLimit
  - Or specifying its type:
  let theValue: TheModule.theType = {noCreditLimit: VALUE}
1 Like

Lambdas are valid, and helpful as you progress towards making a separate function. Those things may be separate functions soon. Like you said they could be accessors!

I reject your “Just add more specificity inside the lambda/accessor” cheat on type annotation the same way i have in other threads :wink: It’s the same thing to me.

I mean, you can look at it as a ‘cheat’, but this is standard practice. It’s how the compiler disambiguates between two different record types with the same field name (I mean, other than using a type annotation, which I personally have found a little flaky). E.g. if you had:

// T.res
type t = {id: string}

// U.res
type u = {id: string, name: string}

// Test.res
let f = x => x.id

How does the compiler know which id it’s referring to? Type inference can’t guess which one you mean, so you give it a hint prefix: x.U.id or whatever. Imho it also helps the long-term readability of the code when you can immediately tell which module the record field is coming from. Code is read much more than it’s written, so this is imho a great trade-off.

3 Likes

This compiles successfully.

x is taken to be type u by proximity, but I could see that the lambda doesn’t have a context to pick a nearest type. that’s helpful.

It compiles if you put them all in a single file, but I put comments there indicating that they would go, in my hypothetical example, in different files with the given names.

If I make a type alias for the U.u type and put it above the function f its still not happy? Why is that?

type t = U.u

let lambda = (x) => x.id

Because a type alias doesn’t impact type inference, unless you ‘re-export’ the definition of the type:

type t = U.u = {id: string, name: string}

Anyway, it’s rare to need this technique, it’s mostly used when different libraries want to interoperate with each other. You wouldn’t really use it in the same project.

Im not satisfied but I think I understand better. thanks =)

When would you need to 're-export" the definition of a type? Do you have an example of a concrete use case?

Sure, here’s an example: Alias result type by ryyppy · Pull Request #3954 · rescript-lang/rescript-compiler · GitHub

This was done to, I guess, make sure the inherited result type and Belt.Result.t were compatible and that Belt.Result.{Ok,Error} continue to work seamlessly even if, one day, the inherited standard library were to be removed.

1 Like

We use that fairly often with types generated by ATDGen, because they tend to be longer and we want to alias them without losing the expanded records when hovering over them. And to be honest, it is a bit tedious; It would be cool if there was at least a keyword/annotation for it.

Like

@alias
type t = ApiType.t

I want to offer a counterpoint to using a module prefix to disambiguate records and variants. I think it’s convenient sometimes, but it’s also fragile. Consider this basic example:

module Either = { type t<'a, 'b> = Left('a) | Right('b)}
let f = x => switch x {
  | Either.Left(x) => Js.log2("left", x) // Module prefix
  | Right(x) => Js.log2("right", x)
  }

This compiles fine and is readable. However, suppose we decide to swap the order of the switch paths:

let f = x => switch x {
-  | Either.Left(x) => Js.log2("left", x)
  | Right(x) => Js.log2("right", x)
+  | Either.Left(x) => Js.log2("left", x)
  }

We get this error:

[E] Line 7, column 4:
The variant constructor Right can't be found.

Since the compiler checks from top to bottom, left to right, it encounters Right before it encounters Either.Left, so it doesn’t know that Right comes from the Either module.

This trivial example is easy to fix, but it becomes a headache when you’re refactoring huge functions. Sometimes the module prefixes could be somewhere deep down one path, and may seem unnecessary until they’re moved.

But this issue never comes up if you just use a type annotation.

let f = (x: Either.t<_>) => switch x {
  | Right(x) => Js.log2("right", x)
  | Left(x) => Js.log2("left", x)
  }

Using module prefixes only for the sake of avoiding type annotations seems like just trading one kind of annotation for another, and doesn’t seem like much of a win to me.

(Module prefixes also aren’t able to disambiguate when constructors or record fields are shadowed within a module, but IMO you shouldn’t be shadowing those to begin with.)

And I want to be clear that I don’t believe that one of these styles is necessarily superior to the other. Sometimes it’s simpler to add a module prefix, and sometimes you want the robustness of the full annotation. I use both in my own code, but lately I’ve been biased towards using annotations more.

1 Like

I can agree on this one. It’s especially painful when a record moves to another module, and the original becomes an alias (the annotation keeps working, but the module prefix doesn’t work anymore).

And because records are nominally typed, it’s not uncommon to move the original to a shared module.

1 Like

That’s interesting, I haven’t experienced records moving around that much. Of course, you have more professional ReScript experience :slight_smile: If I were to move a record type from its own module to a shared types module, however, I imagine I would rename the type, from t to something else.

1 Like