Generating ports with elm-ts-interop

Recently, I tried out elm-ts-interop (v0.0.3 community edition). I suppose it is still under development since there seems to be no specific documentation (except for pro edition) or source code available, but I really like what I have seen so far. There are just a few points confusing me:

If I understand things correctly, elm-ts-interop requires me to write an elm-module (InteropDefinitions.elm say) exposing a function

interop : { fromElm : Encoder input, toElm : Decoder output, flags : Decoder flags }.

Given that module, elm-ts-interop generates a typescript type declaration file specifying exactly two ports called interopFromElm and interopToElm. On elm side, there is nothing generated - I have to write these two ports manually. Although they behave correctly at runtime, I have to pay attention on typing their names correctly, since wrong typings will not cause any exceptions (neither at compile time, nor at runtime).

Question 1: Why does elm-ts-interop generate type declarations for only these two specific ports? Is there any possibility to specify other ports?

Question 2: The module InteropDefinitions.elm is thought as single point of truth for both sides - typescript and elm. Wouldn’t it be consequent to generate an elm module containing the port declarations as well?

1 Like

Apologies for the lack of documentation for the community edition! This is a high priority for me, and I will definitely be working hard to get some nice and thorough documentation for elm-ts-interop Community Edition once I get the elm-pages 2.0 release out!

If there were more ports, then that would be more points of failure that don’t get the benefit of type-safety. The idea is that elm-ts-interop uses a single port pair (to and from Elm) because then you can use those ports to handle all the incoming or outgoing messages you need to with type-safety.

This is similar to the advice given in Murphy Randle’s Elm Conf talk about ports

"The Importance of Ports" by Murphy Randle - YouTube

One of the main features of the pro version is to make it a little more convenient to use this style because it takes top-level definitions like this:

module InteropDefinitions exposing (logInfo, logWarning)

import TsJson.Encode as TsEncode exposing (object, required)

logInfo : TsEncode.Encoder String
logInfo =
    object [ required "message" identity string ]


logWarning : TsEncode.Encoder String
logWarning =
    object [ required "message" identity string ]

And it gives you some helper functions to call them more conveniently:

-- exposed functions in generated InteropPorts module
InteropPorts.logInfo : String -> Cmd msg
InteropPorts.logWarning : String -> Cmd msg

Under the hood, the pro version generates the equivalent of the top-level glue code that you would manually do like this in the community edition:

module InteropDefinitions exposing (Flags, FromElm(..), ToElm, interop)

import Json.Decode as JD
import Json.Encode as JE
import TsJson.Decode as Decode exposing (Decoder)
import TsJson.Encode as Encoder exposing (Encoder, optional, required)


interop : { toElm : Decoder ToElm, fromElm : Encoder FromElm, flags : Decode.Decoder Flags }
interop =
    { toElm = Decode.null ()
    , fromElm = fromElm
    , flags = Decode.null ()
    }


type FromElm
    = LogInfo String
    | LogWarning String


type alias ToElm = ()

type alias Flags = ()


fromElm : Encoder.Encoder FromElm
fromElm =
    Encoder.union
        (\vLogInfo vLogWarning value ->
            case value of
                LogInfo string ->
                    vLogInfo string

                LogWarning string ->
                    vLogWarning string
        )
        |> Encoder.variantObject "LogInfo" [ required "message" identity Encoder.string ]
        |> Encoder.variantObject "LogWarning" [ required "message" identity Encoder.string ]
        |> Encoder.buildUnion

So the core goal is type-safety, and the design of a single port pair is with that goal in mind. The community version gives you that type-safety, and the pro version adds some advanced code generation features that give some extra convenience for using those.

That’s absolutely correct that the definitions module is the point of truth for both sides (TS and Elm). The pro version does some code generation to give some convenience functions, like I described above, so it does generate the ports within that generated code, and it doesn’t expose the ports so they can only be called indirectly through the exposed convenience functions.

The community edition could generate code, but it’s only a single generated module that doesn’t ever change so I think of it more as a scaffolded file than generated code. I’ve considered adding a command to copy the scaffolded module somewhere, but all it would do is copy paste a file like this:

port module GeneratedPorts exposing (decodeFlags, fromElm, toElm)

import InteropDefinitions
import Json.Decode
import Json.Encode
import TsJson.Decode as Decode
import TsJson.Encode as Encode


fromElm : InteropDefinitions.FromElm -> Cmd msg
fromElm value =
    value
        |> (InteropDefinitions.interop.fromElm |> Encode.encoder)
        |> interopFromElm


toElm : Sub msg
toElm =
    (InteropDefinitions.interop.toElm |> Decode.decoder)
        |> Json.Decode.decodeValue
        |> interopToElm


decodeFlags : Json.Decode.Value -> Result Json.Decode.Error InteropDefinitions.Flags
decodeFlags flags =
    Json.Decode.decodeValue
        (InteropDefinitions.interop.flags |> Decode.decoder)
        flags


port interopFromElm : Json.Encode.Value -> Cmd msg


port interopToElm : (Json.Decode.Value -> msg) -> Sub msg

I was weighing the pros and cons of having docs for how to run a command to copy a file like that, versus just having a file like that and instructing people to copy it. I’m open to feedback there, but essentially that’s some of the basic boilerplate that needs to be done one way or another for setting up the elm-ts-interop wiring.

I think it’s worth emphasizing that the interopFromElm and interopToElm ports are not exposed in this module. This is important because it means that you know they are being used only through the TsJson.Decode/Encode APIs, so you get that type-safe contract.

I hope that helps answer those questions! Stay tuned for some docs, and let me know how it goes otherwise, I love to hear feedback.

1 Like

Dear @dillonkearns,

thank you very much for your extremely detailed clarifications which answer my questions to their full extent!

Just to sum things up in my own words:
Instead of generating arbitrarily many ports, etm-ts-interop only generes one port in each direction which can be used as multiplexer. For communication between ts an elm we could use, for example, objects of the form { messageType, payload }. At the receiving side, we implement a switch-case-block on messageType, and by typesystem magic, the corresponding programming language (ts or elm) knows the precise type of payload, given any concrete messageType.

2 Likes

That’s correct! Glad that explanation helped.

Feel free to ask questions any time either here or in the elm-ts-interop slack and I’ll do my best to help!

1 Like

@dillonkearns I have indeed a follow up question:

My current goal is to write some general purpose multiplexer helper functions. The to-elm-part was not too hard:

module InteropDefinitions exposing (interop)

import Json.Encode as JE
import TsJson.Decode as Decode exposing (Decoder)

type ToElm
    = SomeInput Int
    | OtherInput String

toElmMux : List ( String, Decoder toElm ) -> Decoder toElm
toElmMux =
    List.map
        (\( name, decoder ) ->
            Decode.field "tag" (Decode.literal identity (JE.string name))
                |> Decode.andMap (Decode.field "payload" decoder)
        )
        >> Decode.oneOf

interop =
    { toElm =
        toElmMux
            [ ( "SomeInput", Decode.map SomeInput Decode.int )
            , ( "OtherInput", Decode.map OtherInput Decode.string )
            ]
    , fromElm = ...
    , flags = ...
    }

This toElmMux function really makes my life easier since it lets me add further ports without remembering the implementation details of multiplexing.

Now I would like to implement an analogous function fromElmMux which seems a lot harder. I am not even sure what its signature should look like. A function of type

fromElmMux : List (String, fromElm -> Bool, Encoder fromElm ) -> Encoder fromElm

should do the job, but I don’t know if this would become handy in practise since elm seems not to support inline pattern matching (is it possible to write inline fromElm -> Bool guards if fromElm is a union type?).

I would love to hear your thoughts on this issue.

@dillonkearns Yet another follow up question: Is there any way to subscribe to single (to-elm) ports individually, given the multiplexing approach sketched abobve?

At the moment, I only succeeded in subscribing to all ports at once. As you suggested above, I implemented a function toElm which would be exposed instead of the actual port:

toElm : Sub (Result Json.Decode.Error ToElm)
toElm =
    (interop.toElm |> Decode.decoder)
        |> Json.Decode.decodeValue
        |> interopToElm

port interopToElm : (Json.Decode.Value -> msg) -> Sub msg

In my Main.elm-file I subscribed to the toElm function in the following way:

type Msg
    = NoOp
    | NewNumber Int
    | NewString String

subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.map
        (\result ->
            case result of
                Ok toElm ->
                    case toElm of
                        SomeInput x ->
                            NewNumber x

                        OtherInput x ->
                            NewString x

                Err _ ->
                    NoOp
        )
        toElm

This is working, however, it seems way too cumbersome to me.

Is there any way to implement functions

someInput : Sub Int
otherInput : Sub String

which can be subscribed to individually?