fable-compiler/ts2fable

How to specify Indexer in F# to be consumed in JS?

Closed this issue · 2 comments

A TS type with Indexer can be used in F# with the usual Indexer/Item property:

function f(): { readonly [key: string]: string } { ... }

Consume in F#:

type [<AllowNullLiteral>] FReturn =
  [<EmitIndexer>] abstract Item: key: string -> string 

let res: FReturn = f ()
let foo = res.["foo"]


But what if a type with an indexer is consumed in TypeScript and must be created in F#?

function f(t: { [key: string]: string }): void { ... }

In F# the indexer is just a property (-> function), but in TS/JS it's that strange index for its members.
-> Cannot implement Item property in F#.

So instead of implementing an interface it's just ... well ... passing whatever type into the function: (here anon record)

f (!! {| foo = "bar"; lang = "fsharp" |})

BUT: we need to disable all safety features (!!), and there's no check for the type of properties (only string is allowed).




Now to ts2fable:
It currently generates an interface with Item property. Correct for return value, incorrect for input.

But then what to generate for parameters?
Probably just a Tag interface with explicit naming and comments indicating what's expected -- something like:

//...
  abstract f: (t: FTIndexer) -> unit

/// Typescript interface contains indexer `[key: string] -> string`.
/// Unlike in F#, this indexer indexes over a types members.
/// As such in F# the indexer cannot be implemented via `Item` property,
/// but instead just by specifying fields. These fields must by of type `string`.
/// 
/// Easiest way to declare such a type (and most similar to JS/TS usage) is via Anonymous Records, for example:
/// ```fsharp
/// let t = {| Value1 = "foo"; Value2 = "bar" |}
/// ```
/// and 'force' it into the function:
/// ```fsharp
/// f (!! t)
/// ```
///
type FTIndexer =
  interface end

But:

  • Recommends to disable safety stuff...
  • Comments must be adjust to actual usage (like type, names, functions) -> quite complex
  • different F# types have different behaviour:
    • Anon records: produce normal JS object with normal fields -> ok
    • Records: class with ctor which sets its fields -> ok
    • Class: class without fields, property is separate method -> not ok
      • Class with [<AttachMember>]: class with get & set for fields -> ok
    • Interface implementation in class: class with get & set for fields in interface -> ok
    • Anon interface implementation (object expression): normal JS object with normal fields -> ok

-> There are so many different behaviours ... and things to document...and then remember by someone using/implementing it.

And that's when the TS interface just contains the indexer and nothing else. What if it contains an additional field:

interface I {
  readonly type: string
  [name: string]: string
}

For type an interface would be quite useful -- but that complicates the actual implementation... (like mention: "Don't forget [<AttachMembers>] in actual class").

And: Such input is often used in TS/JS for settings objects written by hand directly passed into a function -- which look quite similar to anon records. But these cannot implement interface (I think?). So: use either something else more complex -- or remove all type safeties and just "remember" to specify type too...


And even worse: What if that type I is not just used as function input but somewhere else as function output too? For input we want an interface without Item, but for output we want Item... (Though I haven't seen this case in practice yet)





Any ideas what's best to generate?

Thanks for looking into this @Booksbaum! Yes, this is indeed a tricky situation. Right now, the Emit attribute has an effect on method calls but not in the implementation (well, the implementation is actually ignored). We could try to detect when EmitIndexer is applied to Item and process the implementation, but the added issue is there's no way to implement an indexer in JS the same way we do it in .NET (maybe using a proxy?).

As far as I know the only thing Typescript does is checking at compile time that all properties of the object conform with the signature. The closest thing we have in Fable is this: https://fable.io/docs/communicate/js-from-fable.html#Plain-Old-JavaScript-Objects

image

I regret a bit the feature because it looks like a cheap trick and the errors are only detected by Fable logs, not the F# language service. But at the moment I cannot think of another way to "implement" this rather than extending the !! check to look for indexers too. https://github.com/fable-compiler/Fable/blob/7425cf9fbd945908051e4eb22c9492c4f1265a75/src/Fable.Transforms/FSharp2Fable.Util.fs#L929

I don't known if F# Item with JS Proxy is applicable:
If something is accessed via s["name"] that would work with F# Indexer / Item (via JS Proxy), but if the receiver iterates through the fields the F# Indexer isn't sufficient: There's no way to get all keys. -> Type would need something like IEnumerable<_> that provides all valid inputs/field name.

Though: this issue already exists with consuming Indexer in F#" can index fields, but not iterate over.
But in this case it's possible to work around by emitting some JS (or maybe there's already a Fable helper). For Indexer created in F# that's not possible: There's only the Item function -> no way to get all valid indices for JS Proxy.


Hm, didn't know !! has some typechecking for anon records. That's awesome! (But yeah -- unfortunately it's just in Fable: doesn't provide real IDE support (like completion), only compile time check :( ).

Checking for valid fields with an Indexer would be nice. I think I'll give it a try.