Generating Type-Safe Ports From Custom Types

My elm-typescript-interop NPM package generates type definitions that give you pretty solid type-safety and auto-completion for when you wire in an Elm app and ports from TypeScript (here’s a video demo). I’m trying to give some love to the package right now along with getting it ready for use with Elm 0.19.

And based on this discussion and the ideas from Murphy’s elm-conf talk about ports, I am trying to rethink some of the basic assumptions of elm-typescript-interop. So instead of simply inspecting your Elm source code and finding all the ports you declare, I’m considering using Custom Types and having the library generate an Elm file with ports for you based on your Custom Type definition(s).

Goals

  1. Type-Safety - of course, the primary goal of this package is to make the bridge between Elm and
    TypeScript (and probably ReasonML, Flow, etc. down the line) as failsafe
    as possible.
  2. Be opionionated to ensure nice code -
    If ports are nicer when you share one port for multiple Custom Types variants,
    then let’s make that the way you do things with this library.
  3. Simplicity & Principle of least surprise - That means, generate as little code as possible.
    Use types that the user defines as much as possible, and leverage the Elm compiler
    as much as possible.

I want your feedback and ideas!

I would really really appreciate people’s help in brainstorming. Here are some sketches of what it might look like to generate ports and TypeScript types for Elm interop automaticaly based on a Custom Type definition. (You can see the full working prototype code here).

The user creates two files, src/Ports/LocalStorage.elm and src/Ports/GoogleAnalytics.elm.

module Ports.LocalStorage exposing (FromElm(..), ToElm(..))

import Json.Encode


type FromElm
    = StoreItem { key : String, item : Json.Encode.Value }
    | LoadItem { key : String }
    | ClearItem { key : String }


type ToElm
    = LoadedItem { key : String, item : Json.Encode.Value }
module Ports.GoogleAnalytics exposing (FromElm(..))


type FromElm
    = TrackEvent { category : String, action : String, label : Maybe String, value : Maybe Int }
    | TrackPage { path : String }

The user runs the elm-typescript-interop CLI tool and it generates a Ports.elm file that exposes some functions so you can send Cmds and Subs like so:

-- the Cmd looks like this
Cmd.batch
    [ Ports.localStorage
        (Ports.LocalStorage.StoreItem
            { key = "my-key"
            , item = Json.Encode.int 123456
            }
        )
    , Ports.localStorage (Ports.LocalStorage.LoadItem { key = "my-key" })
    , Ports.googleAnalytics (Ports.GoogleAnalytics.TrackPage { path = "/" })
    ]

subscription =
    Ports.localStorageSubscription GotLocalStorage

You now automatically get auto-completion and type-safety for the following TypeScript code:

import * as Elm from "./src/Main";
let app = Elm.Main.fullscreen();

function assertNever(x: never): never {
  throw new Error("Unexpected object: " + x);
}

app.ports.localStorageFromElm.subscribe(data => {
  if (data.kind === "StoreItem") {
    localStorage.setItem(data.key, JSON.stringify(data.item));
  } else if (data.kind === "ClearItem") {
    localStorage.removeItem(data.key);
  } else if (data.kind === "LoadItem") {
    const getItemString = localStorage.getItem(data.key);
    if (getItemString) {
      app.ports.localStorageToElm.send({
        key: data.key,
        item: JSON.parse(getItemString)
      });
    }
  } else {
    assertNever(data);
  }
});

I would love to hear ideas on this design, possible alternatives, things that
are confusing, etc. Thank you!

5 Likes

Not long ago I was asking in Slack if a lib for generating TS types from Elm existed, happy to stumble on this. I will definitelly try this package.

However, I’m not keen on the idea of using one port for multiple things. Personally, I would prefer if you keep it as is at the moment.

1 Like

Hello @Sebastian, thank you for your feedback on this!

There is a lot of discussion about using a single pair of ports for all outgoing/incoming messages in Elm. I’m trying to understand what lessons to take away from those discussions to apply to this library.

I may be missing something, but these are the pain points I can think of that a single port pair may help to address:

  1. Cluttered up global namespace of ports
  2. Hard to tell which ports are logically grouped (e.g., if you have 3 ports for LocalStorage, 2 for something else, etc.)
  3. You could forget to handle a port? (Although you could also forget an if condition for one of the tags with a port pair, so I’m not sure port pairs address this issue)

For point 1), it could be a nicer experience maybe if ports were generated under a module name. So, for example, if you had a port module LocalStorage with a port valueNotFound you could do app.ports.LocalStorage.valueNotFound.send(...) (instead of something like app.ports.localStorageValueNotFound.send(...)).

It’s possible that elm-typescript-interop could generate some code to help with this. I’m not sure if this is addressing a real issue that people have or not, though. I would love to hear people’s thoughts!

There may be some way to address point 3) using code generated by elm-typescript-interop to make it feel more like the Elm update function where when you add new Msg variant your update function won’t compile until you tell it how to handle it. This could be a cool thing to explore!

Issue 2) seems to be addressed if you declare your ports in modules grouped by domain. I also find that you can sometimes abstract two low-level ports into a single function. Something like this:

port module LocalStorage exposing (read, readEvent, write, ReadEvent(..))

import Json.Encode
import Json.Decode

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

port valueNotFound : (String -> msg) -> Sub msg

type ReadEvent
  = FoundKey String Json.Decode.Value
  | MissingKey String

readEvent : Sub ReadEvent
readEvent =
  Sub.batch [
    Sub.map FoundKey valueReadSuccess
    , Sub.map MissingKey valueNotFound
]

port write : { key : String, value: Json.Encode.Value } -> Cmd msg

A port module like this seems to be a good happy medium for elm-typescript-interop.

2 Likes

Hi @dillonkearns. We have a big Elm app, but with 34 ports. I don’t know if that is a lot or little by other app standards. I read the discussion about grouping ports, but we haven’t had the inclination to do this. I will need to try this approach and see.

For what is worth, our main pain points with ports are:

  1. Confusing naming, the name of the ports don’t give any clue about the direction.
  2. Forget to handle a port
  3. Forget to unhandle a port that has been removed in the Elm side
  4. Passing the wrong things

For 1, we use a naming convention: toElmDoSomething. toJsAskForSomething.
For 2. Don’t know, is there some pattern in TS where the compiler will complain if you don’t handle all possibilities?
For 3. This package would help a lot, the compiler will complain when trying to use an non existing port.
For 4. Same, this lib will be super useful

Thanks, Sebastian

Hello there! Have you heard about peterszerzo/elm-porter before? It allows you to re-use one port (pedantically-speaking: one port-pair) for multiple calls to JavaScript, with the optional possibility for advanced usage to specialize the return types as well, so you could for instance combine all LocalStorage-related behaviour in one Elm port.

1 Like

I looked at peterszerzo/elm-porter, but I didn’t understand how to use it, so I wrote my own package that enables reuse of one port pair by multiple port modules, billstclair/elm-port-funnel. I’m currently in the process of converting all of my port modules to use it.

That’s very unfortunate, because Peter and I tried hard to write good documentation and examples :sweat_smile:. Anything in particular you were confused by? We’d love to improve it!

I was lazy in my last post, bordering on dishonest. I had mostly finished billstclair/elm-port-funnel before I knew about peterszerzo/elm-porter. When I looked at its documentation, I decided that it didn’t do what I wanted, and my ego was invested in my nifty new project, so I soldiered on.

elm-port-funnel has a much more complicated API than elm-porter. I think it’s worth it. Some won’t. It’s a lot of boilerplate if your application uses only one port module. It saves a lot of work, I think, if you use a number of them. I also have so far gotten no feedback on whether the API is so complicated that it’s unusable.

It’s not the most beautiful code in the world, but yes! There is a way to do exhaustive case statements in TypeScript. And it is reliable! They make use of the never type… they look something like this:

enum Color { Red, Green, Blue }

function assertUnreachable(x: never): never {
    throw new Error("Didn't expect to get here");
}

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
    }
    return assertUnreachable(c);
}

You can also do exhaustive checks with Discriminated Unions (if you haven’t heard of discriminated unions in TypeScript, they’re similar to custom types in Elm, just more verbose).

It wouldn’t be too difficult to generate a simple TypeScript file that wraps the Elm ports, groups them by the port module they’re defined in, and lets you do a switch statement on them so you can ensure that they are exhaustive. It could look something like this:

exhaustivePorts(Elm, (fromElm, toElm) => {
  switch (fromElm) {
    InteropHelper.LocalStorage:
      // handle the different Elm-to-JS events here
      // by doing a nested exhaustive check
      // (of course you could do that within a function)

      // and you could send things back with something like
      toElm.LocalStorage.keyNotFound('someKey')
      break;
    default:
      return assertUnreachable(c);
  }
  
});

What are your thoughts on that approach? Does that address most of the pain points that people have with ports, or are there other pain points?

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