Elm-typescript: a codegen tool that makes it super nice to work with ports

Hi!

I wrote a library that IMO makes it super nice to work with ports by generating a bunch of Elm and TypeScript. I wrote it cause I needed it myself, now I’d like some early feedback to see if this is something the community finds interesting as well :slight_smile:

So prior to the pandemic my colleagues and I used to play a round of the board game Ricochet Robots to have a break during the day. When we had to start working from home, we wanted to continue this tradition. But we were unable to find a good online solution. So I wrote one:

https://galactic.run/

It’s very beta, but feel free to test it out :slight_smile: It’s written in Elm and typescript with a firebase backend.

After doing a couple other projects with TypeScript and Elm, it really bothered me that there wasn’t a nice way to ensure the data passed from JS to Elm and back had the right shape. Or that simply the ports were set up correctly. When adding a port, or modifying an existing one, I’d like something to tell me what to fix on the TS side of things (similar to what the Elm compiler does).

I did look at https://github.com/dillonkearns/elm-typescript-interop but it didn’t suit my needs as one of the first things I wanted was to pass union types from TS to Elm which elm-typescript-interop don’t support.

So as part of creating galactic.run, I wrote a CLI that would read a config, and generate the corresponding TS and Elm I needed.

You’ll find the code here:

It’s very early days, so I haven’t event added a README or anything. I’d thought it was a good idea to hear if the community had some early feedback.

For my use case this library just works really well. Since I use firebase I’ll have to write a fair bit TS to do database things and such.

When I now want to send a new message from Elm to TS, I add it to the config, run the elm-typescript tool, and the TS compiler will fail since I don’t handle the new message.

Similarly, if I want to send a message to Elm, I add It to the config. And in TS I now have typed functions to call.

If I want to refactor a type, I just make the change to the config, run the elm-typescript tool and fix any errors in my TS and Elm.

Pretty nice.

Config

The config consist of two main parts. A types section and a ports section.

In the types section you can define records. enums and unions.

Records

What you define in the records section is turned into Elm records, and TS interfaces. As an example, to represent an user, you would add something like:

{
  "types": {
    "records": {
      "User": {
        "uid": "Int",
        "name": "String",
        "role": "Role"
      }
    }
  }
}

The field values can for now be one of String, Int, Bool, Unit, ‘List SomeType’, ‘Dict SomeType’ and any of the records, enums or unions you define yourself.

Dicts always use Strings for keys. I could have made the config require you to say Dict String SomeType but went for the shorter version.

Enums

Enums are turned into Elm custom types and TS enums.

{
  "types": {
    "enums": {
      "Role": [ "Admin", "Regular" ]
    }
  }
}

Unions

Unions are turned into Elm custom types and TS union types.

The reason I added both enums and unions are that TS enums are nicer to use compared to TS union types.

I don’t think it makes sense to keep both the enums and unions sections. So my idea is to make them one, and when I can use TS enums I will, otherwise I’ll use TS union types.

Currently unions are defined like this:

{
  "types": {
    "unions": {
      "Event": {
        "Login": {
          "uid": "Int",
          "timestamp": "Int"
        },
        "Logout": {
          "uid": "Int",
          "timestamp": "Int"
        },
        "Message": {
          "message": "String",
          "timestamp": "Int"
        }
      }
    }
  }
}

I think it was a mistake to use this anonymous record syntax, so my plan is to use the same syntax as record values:

{
  "types": {
    "unions": {
      "Event": {
        "Login": "LoginEvent",
        "Logout": "LogoutEvent",
        "Message": "MessageEvent"
      }
    }
  }
}

And you’ll have to define the records yourself.

Ports

In the ports section you define any incoming and outgoing messages:

{
  "ports": {
    "toElm": {
      "userLoggedIn": "User",
      "eventsFetched": "List Event"
    },
    "fromElm": {
      "logout": "()"
    }
  }
}

Codegen

The types are put in gen/types.ts and 'Gen/Types.elm. In Types.elm` there are also generated encoders and decoders. The only thing missing are encoders for union types. I’ve only used unions to send data to Elm so simply haven’t needed them. But should be easy enough to add.

The ports are put in gen/ports.ts and Gen/Ports.Elm.

TS

To wire up the TS side of things you’ll do something like this:

import Ports from "./gen/ports";
import { User, Role } from "./gen/types";
import { Elm } from "../elm/Main";

const app = Elm.Main.init();

const ports = Ports.init(app, {
  logout: () => {
    // do something to logout the user
  },
});

// when user is logged in

const user: User = {
  uid: 42,
  name: "Smu",
  role: Role.Admin,
};

// the TS type signature enforces only valid `User` types as passed
ports.userLoggedIn(user);

// when events are fetched
// similarly, only a list of `Event` types are allowed
ports.eventsFetched([]);

Oh, I also generate a index.d.ts which is why the import { Elm } from "../elm/Main" bit works

Elm

To wire up the Elm side of things you’d do something like:

import Gen.Ports as Ports

type Msg
    = UserLoggedIn User
    | EventsFetched (List Event)

subscriptions : Model -> Sub Msg
subscriptions _ =
    Ports.subscribe
        { userLoggedIn = UserLoggedIn
        , eventsFetched = EventsFetched
        }

Ports.elm also exposes functions for the outgoing ports ala logout : () -> Cmd msg.

How to try it out

To test it out yourself follow these steps:

npm init -y
npm install elm 
npm install galactic-run/elm-typescript
mkdir src src/elm src/ts

Then we’ll need to init elm:

npx elm init

And install a dependency:

npx elm install elm/json

Then edit elm.json and update source-directories from src to src/elm.

Now to generate some code:

npx elm-typescript init

This will create an example config and generate a bunch of code. Have a look at the config in elm-typescript.json and the corresponding code in str/elm/Gen and src/ts/gen.

If you modify the config you simply run the codegen without the init option npx elm-typescript

If you use parcel bundler, which supports Elm and TS out of the box, you should be able to get going by adding a 'index.html, some TS to wire things up and a Main.elm.

I plan on adding a simplistic working example, and perhaps write some of this brain dump into a more structured blog post or something soon.

Feedback?

So yeah, I wanted to get some feedback from the community before I spend much more time on this. I wrote it out of necessity, and it worked out great for me, so perhaps it’ll be helpful to someone else as well. Let me know what you think :slight_smile:

9 Likes

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