fsharp / fslang-design

RFCs and docs related to the F# language design process, see https://github.com/fsharp/fslang-suggestions to submit ideas
515 stars 144 forks source link

[RFC FS-1030] Discussion: Anonymous record types #170

Closed dsyme closed 5 years ago

dsyme commented 7 years ago

Discussion for https://github.com/fsharp/fslang-design/blob/master/FSharp-4.6/FS-1030-anonymous-records.md

alfonsogarciacaro commented 6 years ago

I've been following this feature and I think it'll be very beneficial for Fable. If implemented, most chances is anonymous F# records would be translated by Fable to Plain JS objects, which is already possible using the Pojo attribute though you still need the nominal type. This means the type won't be a proper instance and won't have reflection metadata attached, but this is probably not very serious because sprintf "%A" and JSON serialization will still work the same as for nominal records. It will also be very helpful to interact with JS libraries (like React) that only accepts plain JS objects and not typed instances. And I'm also very happy to see that anonymous records won't allow prototype or static members, as they're forbidden in Pojo records too which causes confusion sometimes.

About F# semantics in code compiled by Fable, we try to keep a balance between keeping most of F# semantics so users don't have surprises at runtime while also outputting JS as standard as possible and with very low overhead.

But... (and here comes the but) unfortunately I don't think anonymous records will be the panacea for JS interaction in Fable. Most of the times we have to create dynamic JS objects to pass a set of options to a JS library. TypeScript manages to give a statically-typed experience for this thanks to optional properties and structural typing, so the compiler will check if an object conforms to the expected options, even if it doesn't explicit all members. In Fable, we use the option type to represent this, but using a Pojo record still forces the user to make all members explicit, which in most cases is not feasible.

The only way I can think of at the moment to make anonymous records work well for this use case would be to make them compatible with an interface like the following:

// Current Fable syntax to represent optional members
type JSOptions =
    abstract foo: string option with get, set
    abstract bar: int option with get, set

type MyJSLib =
    abstract doSomething: opts: JSOptions -> unit

myJsLib.doSomething({| foo = Some "x" |})

It's been proposed to use static member constraints for this, but it's very cumbersome with current syntax and I think it cannot be applied to interface members. Currently Fable has two tricks to conform with an interface like JSOptions (used in many JS bindings), both of them forces the interface to mark members as mutable:

let opts = createEmpty<JSOptions>
opts.foo <- Some "x"
myJsLib.doSomething(opts)
// JS
// var opts = {}
// opts.foo <- "x"
// myJsLib.doSomething(opts)

// This has also been added recently, it will compile directly to a POJO when possible
myJsLib.doSomething(jsOptions (fun o -> o.foo <- Some "x")
dsyme commented 6 years ago

@alfonsogarciacaro So if anonymous record types included optional properties would this make them more realistically usable for POJO data? e.g.

let myApi (myJsonConfig = {|  id: string; ?x : int; ?y: int  |}) = ...

myApi {| id="1" |}            // passes {| id="1"; x=None; y=None |}
myApi {| id="1"; x = 1 |}  // passes {| id="1"; x=Some 1; y=None |}
myApi {| id="1"; y = 2 |}  // passes {| id="1"; x=None; y=Some 2 |}

Note however that anonymous records are still going to be more rigid than one might expect from typescript etc. - you can't pass an object with more fields where one with few fields is required, or one which has non-optional fields to one where optional fields are specified.

alfonsogarciacaro commented 6 years ago

@dsyme Yes, that should help a lot. The most common scenario where you want to pass a POJO to a JS library is for option configuration, and using Records to type that is too cumbersome because it forces the user to instantiate all the fields at once.

Right now we're using interfaces to translate Typescript definition. It'd be great if anonymous records could also be used that way:

type IConfig =
  abstract id: string
  abstract x: int option
  abstract y: int option

let myApi (myJsonConfig: IConfig) = ...

myApi {| id="1" |}            // passes {| id="1"; x=None; y=None |}
myApi {| id="1"; x = 1 |}  // passes {| id="1"; x=Some 1; y=None |}
myApi {| id="1"; y = 2 |}  // passes {| id="1"; x=None; y=Some 2 |}

...but that's some sort of structural typing (I think there's already a lang suggestion for that) and I guess it's out of scope :)

alfonsogarciacaro commented 6 years ago

I'm thinking of cases where this could be useful. For example, in Fable.React bindings we use the keyValueList trick that converts a list of union cases into a POJO. We could use anonymous records instead so the code looks closer to the React API and we can also start options with lower case. However, we have several methods accepting this object so listing all properties in every signature wouldn't be practicable:

let inline domEl (tag: string) (props: IHTMLProp list) (children: ReactElement list): ReactElement = ...
let inline voidEl (tag: string) (props: IHTMLProp list) : ReactElement = ...
let inline svgEl (tag: string) (props: IProp list) (children: ReactElement list): ReactElement = ...
let inline fragment (props: IFragmentProp list) (children: ReactElement list): ReactElement = ...

See https://github.com/fable-compiler/fable-react/blob/ff7b5ff0033dac63f73a8e0862c3b508addee85d/src/Fable.React/Fable.Helpers.React.fs#L917-L952

Please note this is just an idea and maybe replacing the keyValueList with anonymous records is not entirely desirable. Besides breaking existing code, this would make it more difficult to compose configuration which many users currently do by appending lists.

7sharp9 commented 6 years ago

Im too lazy to analyze the spec again, anonymous records can they be used in a function definition, or is this part of the not supported row polymorphism part?

cartermp commented 6 years ago

A bit late to the party here, but after thinking about this some more, I think 👎 on the principle that these can cross assembly boundaries. I think that the primary utilities of this feature are:

And neither of these really implies that it needs to persist across assembly boundaries. True, you'd need to allocate again to get something to a consumer of the data you gathered, but I think it's better to be more restrictive as a principle now, and if there is a lot of feedback that they ought to cross assembly boundaries, we can do that in a change to the feature. If we go with the assembly boundary principle as-is and then learn that the behavior is confusing and/or abused too easily, we won't be able to go back and will then be forced to try and document "how not to use anonymous records", or something to that extent.

gusty commented 6 years ago

I don't mind withdrawing from "cross assembly" as long as it makes it easy to implement them. I really want to see anonymous records implemented, at least a basic implementation.

wallymathieu commented 6 years ago

Sounds a bit like crossing assembly boundaries gives you a sort of row polymorphism? That can perhaps make it quite complicated to implement since then you'd need to mix different type systems (correct me if I'm wrong @7sharp9 )?

cartermp commented 6 years ago

@gusty They're actually already implemented (and quite complete) in the VF# repo. But what's not clear is:

(a) Good understanding of what they would be used for beyond what is mentioned in my comment (b) If cross-assembly is a good idea (I don't think it is, and right now I wouldn't feel comfortable releasing the feature with that right now) (c) If people fully understand what they are good for and what they are not good for (d) Vectors for abuse that we'll regret later, especially after not letting them "bake" for a while

gusty commented 6 years ago

I think they are mainly useful to store intermediate values when doing data transformation. Right now we have to either use tuples, even if we're dealing with 10 fields, or declare records for all intermediate representations.

Of course, there are other use cases that are very interesting, but I think that's the most important one and the reason to have them available in the language.

dsyme commented 6 years ago

A bit late to the party here, but after thinking about this some more, I think 👎 on the principle that these can cross assembly boundaries.

One major issue is that if they can't cross assembly boundaries, the F# programmer who uses them in a rapid-prototyping mode will quickly find they need to litter their code with private and internal and so on, even though they don't have any potential or imagined consumer of their code, otherwise the compiler will keep giving warnings "this type may escape its assembly scope".

I think that issue alone would mean that the feature tips over from being "worth it" to "not worth it" in many situations. And I have a very strong desire that the routine RAD use of core language features doesn't require the artificial introduction of private and internal annotations.

So in balance my expectation is to keep things as is in the RFC.

cartermp commented 6 years ago

Hmmm, I think I understand. In that case, crossing assembly boundaries is fine. I'm just not that enthused at the concept of anonymous type being used as a publicly consumable contract. Though I suppose that can be documented here

yawaramin commented 6 years ago

@cartermp out of curiosity–why? Anonymous types (aka structural types) are used to good effect in TypeScript. F# could get that same rapid development benefit.

cartermp commented 5 years ago

Further motivation: https://github.com/dotnet/machinelearning/issues/1085

This change to ML.NET also offers a good set of scenarios to validate the feature against.

alfonsogarciacaro commented 5 years ago

This could be a useful feature to write signatures when declaring external functions in Fable:

let foo(x: {| bar: string |}): {| baz: int |} = importMember "./util.js"

My main concern is the structural equality/comparison semantics, because the objects coming from JS won't support it. How is it with C# interaction? Will an F# method whose signature includes anonymous records accept anonymous classes from C#? Apparently, C# anonymous classes have some kind of structural equality but I'm not sure if they support comparison.

cartermp commented 5 years ago

Closing as this is now in.