# elm-duet `elm-duet` provides interop types between Elm and TypeScript by creating a single source of truth using [JSON Type Definitions](https://jsontypedef.com/) (JTD, [five-minute tutorial](https://jsontypedef.com/docs/jtd-in-5-minutes/).) This allows us say precisely what we want and generate ergonomic types on both sides (plus helpers like encoders to make testing easy!) ## JTD? What's that? A JTD is similar to a JSON Schema, except it leaves out features that you can't express in a typical type system. For example, a JSON Schema lets you express as a regex validation on a string, but JTD just allows you to specify the string. This takes away a little bit of the expressive power, but it provides a direct mapping onto the type system. There are 6 typical things you do with JTDs: - `{ "type": "string" }` (or `float64`, or `boolean`, etc) refers to that type directly. - `{ "properties": { "foo": { "type": "string" } } }` gives you an object. - `{ "discriminator": "foo", "mapping": { "bar": { "properties": { "baz": { "type": "string" } } } } }` gives you a discriminated union. In TypeScript, for example, this would produce the type `{ foo: "bar", baz: string }`. - `{ "elements": { "type": "string" } }` gives you a list of values (of whatever shape you like, `string` here) - `{ "values": { "type": "float64" } }` gives you an object with unknown keys, but values of the type you specify (`float64` here) - `{ "enum": ["a", "b"] }` only allows a closed set of values. In Elm, this becomes a custom type. In addition to these, you can define global types and refer to them with `{ "ref": "someName" }`. You can also specify nothing at all by saying `{}`, which is a `()` in Elm and a `Record` in TypeScript. Let's see how we can use these to build up the interop for some sample apps. ## Example 1: JWTs Here's an example for an app that stores [JWTs](https://jwt.io/) in `localStorage`: ```yaml {source=examples/jwt_schema.yaml} # An example schema that uses JWTs to manage authentication. Imagine that the # JWTs are stored in localStorage so that they can persist across sessions. The # lifecycle of this app might look like: # # 1. On init, the JS side passes the Elm runtime the current value of the JWT # (or `null`, if unset) # 2. Elm is responsible for authentication and for telling JS when it gets a # new JWT (for example, when the user logs in) # To start, we'll define a "jwt" that will just be an alias to a string. definitions: jwt: type: string modules: # Now we say how to use it. Each key inside `modules` is the name of an # entrypoint within your Elm app. Here we're saying that this module is named # `Main`, which means we'll be able to access it in TypeScript at `Elm.Main`. Main: # Inside the app, we specify that you have to start the app by providing # the current value. We say that it's nullable because we don't know if the # user is logged in at this point. flags: properties: currentJwt: ref: jwt nullable: true # Next, we set up the port for Elm to tell JavaScript that it should store # a new JWT. Unlike flags, ports have a direction. We specify that we're # passing a message from Elm to JavaScript with `metadata.direction`. ports: newJwt: metadata: direction: ElmToJs ref: jwt ``` (We're using YAML in this example so we can use comments, but JSON schemas also work just fine.) You can generate code from this by calling `elm-duet path/to/your/schema.(yaml|json)`: ```console $ elm-duet examples/jwt_schema.yaml --typescript-dest examples/jwt_schema.ts --elm-dest examples/jwt_schema wrote examples/jwt_schema.ts wrote examples/jwt_schema/Main/Flags.elm wrote examples/jwt_schema/Main/Ports.elm formatted TypeScript formatted Elm ``` This produces this TypeScript file: ```typescript {source=examples/jwt_schema.ts} // Warning: this file is automatically generated. Don't edit by hand! declare module Elm { namespace Main { type Flags = { currentJwt: string | null; }; type Ports = { newJwt?: { subscribe: (callback: (value: string) => void) => void; }; }; function init(config: { flags: Flags; node: HTMLElement }): { ports?: Ports; }; } } ``` This should be flexible enough to use both if you're embedding your Elm app (e.g. with `esbuild`) or referring to it as an external JS file. Notice how the `ports` key and the port itself are optional. This is because you're not *required* to hook up the ports on the Elm side, and if you don't then Elm will omit those keys from the objects you get at runtmie. We also get this file containing Elm flags: ```elm {source=examples/jwt_schema/Main/Flags.elm} module Main.Flags exposing (..) {-| Warning: this file is automatically generated. Don't edit by hand! -} import Dict exposing (Dict) import Json.Decode import Json.Decode.Pipeline import Json.Encode type alias Flags = { currentJwt : Maybe String } flagsDecoder : Json.Decode.Decoder Flags flagsDecoder = Json.Decode.succeed Flags |> Json.Decode.Pipeline.required "currentJwt" (Json.Decode.nullable Json.Decode.string) encodeFlags : Flags -> Json.Encode.Value encodeFlags flags_ = Json.Encode.object [ ( "currentJwt" , case flags_.currentJwt of Just value -> Json.Encode.string value Nothing -> Json.Encode.null ) ] ``` In your `init`, you can accept a `Json.Decode.Value` and call `Decode.decodeValue Main.Flags.flagsDecoder flags` to get complete control over the error experience. It also lets you custom types in your flags, since you're specifying the decoder. Note that `elm-duet` creates both decoders and encoders for all the types it generates. This is to make your life easier during testing: you can hook up tools like [elm-program-test](https://package.elm-lang.org/packages/avh4/elm-program-test/latest/) without having to write separate encoders just to test. Finally, we have the ports: ```elm {source=examples/jwt_schema/Main/Ports.elm} port module Main.Ports exposing (..) {-| Warning: this file is automatically generated. Don't edit by hand! -} import Dict exposing (Dict) import Json.Decode import Json.Decode.Pipeline import Json.Encode type alias NewJwt = String newJwtDecoder : Json.Decode.Decoder NewJwt newJwtDecoder = Json.Decode.string encodeNewJwt : NewJwt -> Json.Encode.Value encodeNewJwt newJwt_ = Json.Encode.string newJwt_ port newJwt : Json.Decode.Value -> Cmd msg sendNewJwt : NewJwt -> Cmd msg sendNewJwt = encodeNewJwt >> newJwt ``` You'll notice that in addition to decoders and encoders, `elm-duet` generates type-safe wrappers around the ports. This is to let you send custom types through the ports in a way we control: if you specify an `enum`, for example, we ensure that both Elm and TypeScript have enough information to take advantage of the best parts of their respective type systems without falling back to plain strings. Other than those helpers, we don't try to generate any particular helpers for constructing values. Every app is going to have different needs there, and we expose everything you need to construct your own. ## Example 2: An All-in-One Port Some people like to define an all-in-one port for their application to make sure that they only have a single place to hook up new messages. `elm-duet` and JDT support this with discriminators and mappings: ```yaml {source=examples/all_in_one.yaml} # Let's imagine that we're writing an app which handles some websocket messages # that we want Elm to react to. We'll leave it up to Elm to interpret the data # inside the messages, but we can use elm-duet to ensure that we have all the # right types for each port event set up. # # For this example, we're going to define a port named `toWorld` that sends all # our messages to JS in the same place, and the same for `fromWorld` for # subscriptions. We could do this across many ports as well, of course, but if # you prefer to put all your communication in one port, here's how you do it! modules: Main: ports: toWorld: metadata: direction: ElmToJs # JTD lets us distinguish between different types of an object by using # the discriminator/mapping case. The first step is to define the field # that "tags" the message. In this case, we'll literally use `tag`: discriminator: tag # Next, tell JTD all the possible values of `tag` and what types are # associated with them. We'll use the same option as in the JWT schema # example, but all in one port. Note that all the options need to be # non-nullable objects, since we can't set a tag otherwise. mapping: connect: properties: url: type: string optionalProperties: protocols: elements: type: string send: properties: message: type: string close: properties: code: type: uint8 reason: type: string # since we're managing the websocket in JS, we need to pass events back # into Elm. Like `toWorld`, we'll use discriminator/mapping from JTD. fromWorld: metadata: direction: JsToElm discriminator: tag mapping: # There isn't any data in the `open` or `error` events, but we still # care about knowing that it happened. In this case, we specify an # empty object to signify that there is no additional data. open: {} error: {} close: properties: code: type: uint32 reason: type: string wasClean: type: boolean message: properties: data: type: string origin: type: string ``` Like the other example, we'll store all our output in `examples`: ```console $ elm-duet examples/all_in_one.yaml --typescript-dest examples/all_in_one.ts --elm-dest examples/all_in_one wrote examples/all_in_one.ts wrote examples/all_in_one/Main/Ports.elm formatted TypeScript formatted Elm ``` We get this in TypeScript: ```typescript {source=examples/all_in_one.ts} // Warning: this file is automatically generated. Don't edit by hand! declare module Elm { namespace Main { type Flags = Record; type Ports = { fromWorld?: { send: ( value: | { code: number; reason: string; tag: "close"; wasClean: boolean; } | { tag: "error"; } | { data: string; origin: string; tag: "message"; } | { tag: "open"; }, ) => void; }; toWorld?: { subscribe: ( callback: ( value: | { code: number; reason: string; tag: "close"; } | { protocols?: string[]; tag: "connect"; url: string; } | { message: string; tag: "send"; }, ) => void, ) => void; }; }; function init(config: { flags: Flags; node: HTMLElement }): { ports?: Ports; }; } } ``` The Elm for this example is too long to reasonably include in a README. See it at `examples/all_in_one/Main/Ports.elm`. Like the previous example, you get all the data types and ports you need, plus some wrappers around the ports that will do the decoding for you. ## Is it any good? I mean, the answer is supposed to be just an unqualified "Yes" right? But this is pre-1.0 software. It's not gonna steal your lunch money or eat your toaster, but it's not perfect. In particular: - It has only been used for one fairly small app so far. There are probably combinations in the JTD spec that it does not handle well. - Types are generated independently for ports and flags. This is mostly fine, but if you share an `enum` or `discriminator`/`mapping` between the two halves, you'll have two distinct custom types. It's easy enough to get around since there's a 1:1 mapping, but it's a little more code you have to write for now. - Ports are generated all in one file. This makes it very easy to track what's where, but sometimes means having long or conflicting names. You can get around this with `metadata.name` or `metadata.constructorPrefix`, but a better future solution would be to generate in different files. - Records in Elm are always generated as type aliases. This makes the error message quality a bit worse. ## The Full Help Here's the full help to give you an idea of what you can do with the tool: ```console $ elm-duet --help Generate Elm and TypeScript types from a single shared definition. Usage: elm-duet [OPTIONS] Arguments: Location of the definition file Options: --typescript-dest Destination for TypeScript types [default: elm.ts] --elm-dest Destination for Elm types [default: src/] --no-format Turn off automatic formatting discovery --ts-formatter What formatter should I use for TypeScript? (Assumed to take a `-w` flag to modify files in place.) [default: prettier] --elm-formatter What formatter should I use for Elm? (Assumed to take a `--yes` flag to modify files in place without confirmation.) [default: elm-format] -h, --help Print help -V, --version Print version ``` ## License BSD 3-Clause, same as Elm.