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
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:
It’s very beta, but feel free to test it out 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