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

https://www.youtube.com/watch?v=P3pL85n9_5s

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?

After rethinking for a while, I realized why building a multiplex-encoder has to be a lot more complicated then a corresponding decoder. Nevertheless, I’ve implemented some general purpose helper functions for building (de-)multiplexing encoders/decoders which help keeping the InteropDefinitions.elm file clean.

port module InteropHelpers exposing
    ( MuxBuilder
    , MuxValue
    , PayloadDecoder(..)
    , buildMux
    , demux
    , elm2tsInterop
    , emptyMux
    , initMux
    , loadedMux
    , ts2elmInterop
    )

import Json.Decode as JD
import Json.Encode as JE
import TsJson.Decode as Decode exposing (Decoder)
import TsJson.Encode as Encode exposing (Encoder, UnionBuilder, UnionEncodeValue, buildUnion)



-- multiplexing


type MuxBuilder a
    = MuxBuilder (UnionBuilder a)


type alias MuxValue =
    UnionEncodeValue


initMux : constructor -> MuxBuilder constructor
initMux =
    Encode.union >> MuxBuilder


loadedMux : String -> Encoder val -> MuxBuilder ((val -> MuxValue) -> tail) -> MuxBuilder tail
loadedMux name encoder (MuxBuilder builder) =
    Encode.variantObject name [ Encode.required "payload" identity encoder ] builder
        |> MuxBuilder


emptyMux : String -> MuxBuilder (MuxValue -> tail) -> MuxBuilder tail
emptyMux name (MuxBuilder builder) =
    Encode.variant0 name builder
        |> MuxBuilder


buildMux : MuxBuilder (fromElm -> MuxValue) -> Encoder fromElm
buildMux (MuxBuilder builder) =
    buildUnion builder



-- demultiplexing


type PayloadDecoder toElm
    = NoPayload toElm
    | WithPayload (Decoder toElm)


demux : List ( String, PayloadDecoder toElm ) -> Decoder toElm
demux =
    List.map
        (\( name, payloadDecoder ) ->
            Decode.field "tag" (Decode.literal identity (JE.string name))
                |> Decode.andMap
                    (case payloadDecoder of
                        NoPayload result ->
                            Decode.succeed result

                        WithPayload decoder ->
                            Decode.field "payload" decoder
                    )
        )
        >> Decode.oneOf



-- interop2elm


ts2elmInterop : Decoder toElm -> msg -> (toElm -> msg) -> Sub msg
ts2elmInterop decoder noop toMsg =
    Decode.decoder decoder
        |> JD.decodeValue
        |> interopToElm
        |> Sub.map
            (\result ->
                case result of
                    Ok data ->
                        toMsg data

                    Err _ ->
                        noop
            )


port interopToElm : (JD.Value -> msg) -> Sub msg



-- interop2ts


elm2tsInterop : Encoder fromElm -> fromElm -> Cmd msg
elm2tsInterop encoder =
    Encode.encoder encoder
        >> interopFromElm


port interopFromElm : JE.Value -> Cmd msg

This module is completely static and could theoretically be included into the elm-ts-json library (although you probably want to avoid redundancy). My actual InteropDefinitions.elm file now looks quite clean and readable.

module InteropDefinitions exposing (Flags, interop, receiveFromTS, sendSomethingElseToTS, sendSomethingToTS)

import InteropHelpers exposing (PayloadDecoder(..), buildMux, demux, elm2tsInterop, emptyMux, initMux, loadedMux, ts2elmInterop)
import TsJson.Decode as Decode exposing (Decoder)
import TsJson.Encode as Encode exposing (Encoder)


type alias Flags =
    ()


type ToElm
    = SomeInput Int
    | OtherInput


type FromElm
    = SomeOutput String
    | OtherOutput


interop : { toElm : Decoder ToElm, fromElm : Encoder FromElm, flags : Decoder Flags }
interop =
    { toElm =
        demux
            [ ( "SomeInput", WithPayload (Decode.int |> Decode.map SomeInput)  )
            , ( "OtherInput", NoPayload OtherInput )
            ]
    , fromElm =
        initMux
            (\some other value ->
                case value of
                    SomeOutput x ->
                        some x

                    OtherOutput ->
                        other
            )
            |> loadedMux "SomeOutput" Encode.string
            |> emptyMux "OtherOutput"
            |> buildMux
    , flags = Decode.null ()
    }



-- interop functions to be exposed


sendSomethingToTS : String -> Cmd msg
sendSomethingToTS text =
    elm2tsInterop interop.fromElm (SomeOutput text)


sendSomethingElseToTS : Cmd msg
sendSomethingElseToTS =
    elm2tsInterop interop.fromElm OtherOutput


receiveFromTS : msg -> (Int -> msg) -> msg -> Sub msg
receiveFromTS noop someHandler otherHandler =
    ts2elmInterop interop.toElm
        noop
        (\val ->
            case val of
                SomeInput num ->
                    someHandler num

                OtherInput ->
                    otherHandler
        )

There’s an inherent complexity which is that TypeScript is not a sound type system like Elm’s, it is merely a guide. It gives you added safety, but I give the option to handle the error case since it’s still possible to bypass the TypeScript type system. You could create application-specific helpers to ignore the error case, or send it to an error tracker, etc., depending on your use case and application setup.

For subscribing to an individual toElm port, you would still need to handle that error case in some way, so it can be nice to handle all the ports in one place so you don’t have to handle the Err case more than once. There are benefits to picking out the ports in a single place, similar to the benefits of having a case expression for an update function.

You could create a helper to ignore all other cases, but I always try to make safe abstractions the top priority, and then convenience helpers that may be less safe can always be derived (for example, ignoring Err cases or potentially ignoring other ports and forgetting to handle them).

Hopefully that helps a little bit!

1 Like

Absolutely, that’s very convincing!

On top of that, I found an old discussion somewhere, whether the Sub type deserves a filter function or not. It seems that nowadays, it is actually impossible to filter subscriptions (without sending NoOp messages) in elm. So there is no meaningful way at all to convert Sub ToElm to Sub Int.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.