mirror of
https://github.com/BrianHicks/elm-duet.git
synced 2024-11-22 21:32:17 +03:00
420 lines
14 KiB
Markdown
420 lines
14 KiB
Markdown
# 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<string, never>` 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<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.
|
|
|
|
## 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] <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
|
|
|
|
```
|
|
|
|
## License
|
|
|
|
BSD 3-Clause, same as Elm.
|