I haven’t announced it officially yet, but I’m working on a project that may work nicely with this use case! I’ll give a preview of it here in case it might be a good fit.
Approach
I’m looking for advice on how go about programmatically generating decoders based off of a back-end written in TypeScript.
I’ve thought about the ideal approach to this sort of thing a lot. If you have a type in one space, what’s the best way to transfer that typed data between runtimes/languages? GraphQL, for example, has a way to describe a typed, fairly expressive serialization format. So you translate from TypeScript, Haskell, Rust, JavaScript, Postgres, or other languages into this GraphQL serialization format. Then on the client, you have that well-typed data.
If you try to consume arbitrary TypeScript types, then you also need to define some sort of intermediary serialization format, since there are some typescript types that cannot be directly serialized (functions, classes, etc.).
The use case you’re describing isn’t actually the intended use case for the project I’ve been working on, but I think it fits well, and in part because of the underlying principle. TypeScript allows you to put expressive types onto pure JSON data. JSON is already a serializable/de-serializable format. So you can directly send and decode it, and just describe your domain through putting constraints on that JSON. For example, you can use literals, like type severity = 'info' | 'warning' | 'error'
. The data format is just plain JSON, but you can put constraints on it. You can also use the discriminated union technique to express the equivalent of a custom type.
So rather than going to the trouble of generating TypeScript code to serialize, as well as generating Elm decoders based on your TypeScript types (a very complex and messy problem), you can approach it from the other side. Write an Elm decoder, and then generate a TypeScript type to describe the valid JSON values that the Elm decoder will succeed in decoding. It actually turns out to be extremely expressive because of how expressive Decoders are in Elm, and how expressive TypeScript is for JSON with literal types and untagged unions.
Example
type TestResult
= Pass
| Fail String
testCaseDecoder : InteropDecoder TestResult
testCaseDecoder =
oneOf [
field "tag" (literal Pass (Json.Encode.string "pass"))
, map2 (\() message -> Fail message)
( field "tag" (literal () (Json.Encode.string "fail")) )
( field "message" string )
]
oneOrMore (::) testCaseDecoder
|> runExample """[ { "tag": "pass" } ]"""
--> { decoded = Ok [ Pass ]
--> , tsType = """
[
{ tag : "pass" } | { tag : "fail"; message : string }
...({ tag : "pass" } | { tag : "fail"; message : string })[]
]
"""
--> }
I’ll be announcing the library I’m working on soon. It’s a rewrite and new approach to elm-typescript-interop
. I had a similar realization there. The previous version looked at the Elm AST and determined the names and types of all the ports that could be used. But this eventually runs up against limitations because the types can’t be as expressive, so for example you can’t send Custom Types, or get TypeScript annotations with more sophisticated types like Unions, Intersections, etc. Approaching the problem from the other end, and starting by writing a decoder then deriving the type by how the decoder runs, surprisingly yields a lot more expressive power. It works nicely for encoding as well, though it required a few clever designs to get everything lining up properly!
Anyway, I think this could be a really powerful technique in general for keeping data in sync between TypeScript and Elm codebases. I’d be very interested to hear your thoughts if that sounds interesting!