elm-duet
provides interop types between Elm and TypeScript by creating a single source of truth using JSON Type Definitions (JTD, five-minute tutorial.)
This allows us say precisely what we want and generate ergonomic types on both sides (plus helpers like encoders to make testing easy!)
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" }
(orfloat64
, orboolean
, 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<string, never>
in TypeScript.
Let's see how we can use these to build up the interop for some sample apps.
Here's an example for an app that stores JWTs in localStorage
:
# 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)
:
$ 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:
// 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:
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 without having to write separate encoders just to test.
Finally, we have the ports:
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.
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:
# 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
:
$ 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:
// Warning: this file is automatically generated. Don't edit by hand!
declare module Elm {
namespace Main {
type Flags = Record<string, never>;
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.
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
ordiscriminator
/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
ormetadata.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.
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/]
--no-format
Turn off automatic formatting discovery
--ts-formatter <TS_FORMATTER>
What formatter should I use for TypeScript? (Assumed to take a `-w` flag to modify files in place.) [default: prettier]
--elm-formatter <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
BSD 3-Clause, same as Elm.