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
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?
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
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.
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.
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
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?).
@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.
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).
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.