Type alias does not get applied in some cases
In the example below, the type alias is not present on map, but is on the operator. Note that it doesn't matter whether the second binding is an operator or just another function.
Repro steps
Take this code:
type ValidationErrors = string list
type Validator<'a, 'r> = string -> 'a -> Result<'r, ValidationErrors>
let map (f: Validator<'a, 'b>) (g: 'b -> 'c): Validator<'a, 'c> =
fun a b ->
match f a b with
| Ok x -> Ok(g x)
| Error x -> Error x
let (<!>) f g = map f g
Expected behavior
The types shown in either the tooltips in VS or VSCode, or in FSI, should be as follows:
val map : f:Validator<'a,'b> -> g:('b -> 'c) -> Validator<'a,'c>
val (<!>) : f:Validator<'a,'b> -> g:('b -> 'c) -> Validator<'a,'c>
Actual behavior
But they aren't. The first of the functions doesn't honor the specific type annotation:
val map :
f:Validator<'a,'b> ->
g:('b -> 'c) -> a:string -> b:'a -> Result<'c,ValidationErrors>
val ( <!> ) : f:Validator<'a,'b> -> g:('b -> 'c) -> Validator<'a,'c>
Known workarounds
None found yet, except for just shadowing the function an extra time. I.e., this solves it for the public functions/operators, but is clumsy:
let private map_ (f: Validator<'a, 'b>) (g: 'b -> 'c): Validator<'a, 'c> = fun a b -> Result.map g (f a b)
let map f g = map_ f g
let (<!>) f g = map f g
Gives:
val private map_:
f: Validator<'a,'b> ->
g: ('b -> 'c) -> a: string -> b: 'a -> Result<'c,ValidationErrors>
val map: f: Validator<'a,'b> -> g: ('b -> 'c) -> Validator<'a,'c>
val (<!>) : f: Validator<'a,'b> -> g: ('b -> 'c) -> Validator<'a,'c>
Another alternative might be to use an fsi file for the vals, which probably also works.
Related information
I should note that let map = map_ in the above example keeps the erroneous type. As soon as I repeat the args, it picks the correct type.
Tested with VS 2012 / 2015 / 2022 / 2022 Latest Preview. Behaves the same in all of them, so this bug has been around for a while.
I poked around a little bit. Here is a simpler repro in an fsi session:
> type T = unit -> unit;;
type T = unit -> unit
> let f x : T = fun _ -> x;;
val f: x: unit -> unit -> unit
> let g x = f x;;
val g: x: unit -> T
> g;;
val it: (unit -> unit -> unit) = <fun:it@4>
Note the last entry - after retyping g it's printed type changed from unit -> T to unit -> unit -> unit, so type alias got "unapplied".
Also this:
> let f2 x : T =
let k _ = ()
k;;
val f2: x: 'a -> T
> f2;;
val it: ('a -> unit -> unit)
This also affects signature files generated with fsc.exe: Foo.fs:
module M
type T = unit -> unit
let f (x : unit) : T = fun _ -> x
let g x = f x
let f2 (x : unit) : T =
let k _ = ()
k
Ran fsc.exe --sig:Foo.fsi Foo.fs
Resulting Foo.fsi (note that type alias is not used in f's signature):
module M
type T = unit -> unit
val f: x: unit -> unit -> unit
val g: x: unit -> T
val f2: x: unit -> T
Seems to only affect aliases of function types. Not sure what is the root cause for this yet. However, the impact is rather low as far as I can tell.
This is not quite as it seems.
In F#, a module-declared function has a statically known number of arguments based on its syntactic declaration, e.g.
let f0 (x: int) = x+1
let f1 = fun x -> x+1
let f2 = printfn "hello"; fun x -> x+1
By default (with no signature file) here f0 and f1 both result in a static method with exactly one argument - while f2 results in function-valued property. For f1 the immediate use of fun x -> ... means it is considered a static function definition.
This difference is visible in the types in signature files, so the signature declarations are:
val f0 : int -> int
val f1 : int -> int
val f2 : (int -> int)
Now if int -> int is abbreviated, then using this abbreviation in the signature is not neutral - it changes the compiled form, so you can write a signature file like this:
type T = int -> int
val f0 : T
val f1 : T
val f2 : T
But this is a different compiled form - three static properties (rather than two static functions and a static property).
Because of this, when we report the types of statically compiled things we must expand the abbreviations of function types to the point where the accurately reflect the compiled forms inferred by default. That is, consider the following code:
type T = int -> int
let f0 : T = (fun x -> x+1)
Here in the absence of a signature file f0 is still inferred as a static function despite appearances. This is part of the F# spec. This means it would be incorrect to report its type as
val f0: T
We actually have no real choice but to report it as the honest signature
val f0: x: int -> int
which indicates that f0 is indeed a static function.
So this is by design. There could be some possible improvement to somehow give a comment
// f0: T - the type has been expanded as the value is compiled as a static function
val f0: x: int -> int
but that's not going to be a priority as things stand.
Thanks @dsyme for diving into this. It's very subtle, but in the end I guess it makes sense. You specifically said "in the absence of a signature file". Using that here is probably a more stable way of getting type aliases to appear in signatures.
Tbh, I wasn't aware that let f = fun x -> 42 and let f x = 42 are both considered static functions (maybe for the purposed of the compiled functions, but not for the purpose of the F# type system). I've often wondered why on some functions, the signature was int -> int and other (int -> int). There are obvious cases, but here I couldn't figure it out. Thanks again!