Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Intersection types? #2655

Open
cannorin opened this issue Dec 12, 2021 · 7 comments
Open

[Proposal] Intersection types? #2655

cannorin opened this issue Dec 12, 2021 · 7 comments

Comments

@cannorin
Copy link
Contributor

Fable has predefined types U2, U3, ... so that tools such as ts2fable can use it to bind to union types coming from TypeScript.

But Fable lacks the intersection counterpart, so ts2fable has no option but emitting obj for intersection types, which is not very useful. How about adding I2, I3, ... types to fill the gap?

The basic concept is described in the example below (online repl):

open Fable.Core

let inline (!%) (x: ^X) = (^X: (static member op_ErasedCast2: ^X * 'a -> 'a) x, Unchecked.defaultof<_>)

type [<Erase; RequireQualifiedAccess>] I2<'a, 'b> = Box of obj with
  static member inline op_ErasedCast2(Box x, _target:'a) = x :?> 'a
  static member inline op_ErasedCast2(Box x, _target:'b) = x :?> 'b

type A = {| foo: string |}
let fa (a: A) = printfn "A: %s" a.foo

type B = {| bar: float  |}
let fb (b: B) = printfn "B: %f" b.bar

type AB = I2<A, B>

let ab: AB = I2.Box {| foo = "foo"; bar = 42 |}
fa !%ab
fb !%ab

If we are to add these types to Fable.Core, I think it's better to extend the existing !^ operator to support intersection types rather than making a new operator, but then op_ErasedCast now has to include the return type as a dummy argument, which would be a breaking change. What do you think?

@cannorin
Copy link
Contributor Author

Note that I'm willing to author a PR for this if we are going to add this 🙂

@alfonsogarciacaro
Copy link
Member

This is an interesting proposal. Right now there's no particular machinery in Fable for erased union types, as the F# compiler already checks the types when using the !^. By a PR, besides adding the types to Fable.Core, I assume you mean adding code to Fable so you can check the type contains the proper fields (as we do with !! and anonymous records)?

The drawback would be adding more tricks to Fable that won't work in standard F#. Also we're trying to make FSharp2Fable language agnostic in Fable 4 but this would likely add some JS-specific code (as with the !! check) unless we find a way to abstract these language-specific checks or move them to a later step.

Thoughts? @Booksbaum @MangelMaxime @Zaid-Ajaj @ncave @dbrattli

@cannorin
Copy link
Contributor Author

The field checking you described is nice to have, but I think it's not a requirement.

If I understand correctly, U2 checks that we can safely construct U2 values through op_ErasedCast, but it does not guarantee destructing (matching) U2 values works correctly too.

Unions and intersections are dual, so this would mean: I2 should check that we can safely destruct I2 values (through op_ErasedCast2), but it doesn't have to guarantee that constructing I2 values (through I2.Box) works.

@MangelMaxime
Copy link
Member

If Intersection types can helps improve bindings generation I am interesting in having the feature available.

For information:

As discuss with @cannorin in private, improving TypeScript to Fable will be my next big project once I am done with API doc generation from Nacara.

@alfonsogarciacaro
Copy link
Member

I have the feeling that if we add this feature we should have some field checking when constructing intersection types. If we don't do it, it won't be much of an improvement over just using obj. Not sure we can say F# type checking ensures safe destructing because we can put anything on I2 so the check will always succeed.

It's not as advanced as in Typescript, but erased unions do guarantee safe destructing to some extent by compiling pattern matching as type testing (as we saw here). This works:

type Foo() =
  member _.Foo = "foo"

let test (x: U3<string, float, Foo>) =
  match x with
  | U3.Case1 x -> printfn $"This is a string: %s{x}"
  | U3.Case2 x -> printfn $"This is a number: %f{x}"
  | U3.Case3 x -> printfn $"This is a class: %s{x.Foo}"

// Compiled as:
// export function test(x) {
//     if ((typeof x) === "number") {
//         const x_2 = x;
//         toConsole(interpolate("This is a number: %f%P()", [x_2]));
//     }
//     else if (x instanceof Foo) {
//         const x_3 = x;
//         toConsole(`This is a class: ${Foo__get_Foo(x_3)}`);
//     }
//     else {
//         const x_1 = x;
//         toConsole(`This is a string: ${x_1}`);
//     }
// }  

The only thing we miss is checking that Fable can actually tell apart the cases by type testing when doing pattern. We can try to implement it though it's not trivial. For example, Fable compiles type testing of both int and float as type of "number".

About the operator, I agree we should use !^. The breaking change shouldn't be an issue for Fable, because we always compile sources but it can trigger weird message errors when during binary (dotnet) builds so we must be careful.

@alfonsogarciacaro
Copy link
Member

BTW, I forgot to mention that we already have a similar field checking when casting an anonymous record into an interface (see the note about the !! operator in the documentation about interop) so I expect we can reuse the feature. The code is here (it's quite large now so probably we should move to its own file):

let fitsAnonRecordInInterface
(_com: IFableCompiler)
(range: SourceLocation option)
(argExprs: Fable.Expr list)
(fieldNames: string array)
(interface_: Fable.Entity)
=
match interface_ with
| :? FsEnt as fsEnt ->
let interface_ = fsEnt.FSharpEntity
let interfaceMembers =
getAllInterfaceMembers interface_
|> Seq.toList

@cannorin
Copy link
Contributor Author

cannorin commented Apr 17, 2022

I managed to modify !^ without breaking the existing op_ErasedCast codes (live)

open Fable.Core
open System.ComponentModel

type [<Erase; RequireQualifiedAccess>] I2<'a, 'b> = Box of obj with
  [<Emit("$0")>] static member op_ErasedCast2(_: I2<'a, 'b>, _:'a) = jsNative : 'a
  [<Emit("$0")>] static member op_ErasedCast2(_: I2<'a, 'b>, _:'b) = jsNative : 'b

type [<Erase; EditorBrowsable(EditorBrowsableState.Never)>] ErasedCast =
  static member inline op_ErasedCast2(x: 'a, _: ^U) : ^U = Fable.Core.JsInterop.(!^) x
  static member inline op_ErasedCast2(_: ErasedCast, _target) = _target // dummy overload
  static member inline Invoke (x: ^T1) : ^T2 =
    let inline call (_: ^i) (x: ^x) =
      ((^i or ^x): (static member op_ErasedCast2: ^x * ^T2 -> ^T2) x,Unchecked.defaultof<_>)
    call Unchecked.defaultof<ErasedCast> x

let inline (!^) (x: 'T1) : 'T2 = ErasedCast.Invoke x

let x : U2<int, string> = !^42

let i : I2<int, float> = I2.Box 42
let y : int = !^i

let z : U2<int, string> = !^ !^i

I also tested this modified !^ with the nestable union type I proposed in #2858, and it worked without problem (live).

Also, I think op_ErasedCast2 types should use [<Emit("$0")>] to optimize the output (I tried inline but it generated unnecessary intermediate variables).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants