bytes.zone

elm-duet

Brian Hicks, April 23, 2024

Just a little update on tinyping: I have a system that mostly works! Yay! It doesn't do reporting, but it'll alert you about new pings correctly.

This actually doesn't feel like a huge win to me because it's so, so messy. I'm still using Automerge for this, and I think it's a solid decision, but taking the distributed nature of the system into account while building this has been both tricky and instructive.

Anyway, while making what I have of tinyping so far, I kept running into issues with syncing information across the Elm/TS boundary. It's really annoying to have to update the types on both sides. This has bugged me for a while across multiple projects, even at work, so I decided to dive down the rabbit hole and try to fix this. Here's a preview in the form of the current README of the project:


elm-duet

Elm is great, and TypeScript is great, but the flags and ports between them are hard to use safely. They're the only part of the a system between those two languages that aren't typed by default.

You can get around this in various ways, of course, either by maintaining a definitions by hand or generating one side from the other. In general, though, you run into a couple different issues:

elm-duet tries to get around this by creating a single source of truth to generate both TypeScript definitions and Elm types with decoders. We use JSON Type Definitions (JTD, five-minute tutorial) to say precisely what we want and generate ergonomic types on both sides (plus helpers like encoders to make testing easy!)

Here's an example for an app that stores a jwt in localStorage or similar to present to Elm:

{
  "modules": {
    "Main": {
      "flags": {
        "properties": {
          "currentJwt": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ports": {
        "newJwt": {
          "metadata": {
            "direction": "ElmToJs"
          },
          "type": "string"
        },
        "logout": {
          "metadata": {
            "direction": "ElmToJs"
          }
        }
      }
    }
  }
}

You can generate code from this by calling elm-duet path/to/your/schema.json:

$ elm-duet examples/jwt_schema.json --typescript-dest examples/jwt_schema.ts
wrote examples/jwt_schema.ts

Which results in this schema:

// Warning: this file is automatically generated. Don't edit by hand!

declare module Elm {
  namespace Main {
    type Flags = {
      currentJwt: string | null;
    }

    type Ports = {
      logout: {
        subscribe: (callback: (value: Record<string, never>) => void) => void;
      };
      newJwt: {
        subscribe: (callback: (value: string) => void) => void;
      };
    }

    function init(config: {
      flags: Flags;
      node: HTMLElement;
    }): void
  }
}

(Elm code generation is currently TODO.)

Here's the full help to give you an idea of what you can do with the tool:

$ elm-duet --help
Generate Elm and TypeScript types from a single shared definition.

Usage: elm-duet [OPTIONS] <SOURCE>

Arguments:
  <SOURCE>  Location of the definition file

Options:
      --typescript-dest <TYPESCRIPT_DEST>  Destination for TypeScript types [default: elm.ts]
      --elm-dest <ELM_DEST>                Destination for Elm types [default: src/]
      --elm-prefix <ELM_PREFIX>            Prefix for Elm module path [default: Interop]
  -h, --help                               Print help
  -V, --version                            Print version

If you'd like, I can keep you updated about thing-a-month. Stick your email in the box!