Most discussions on JSON decoders in Elm are about decoding records. All other types of values are fairly easy to decode – there’s usually a function in Json.Decode
with the same name.
You’ve probably seen the solutions to record decoding people have come up with:
- https://package.elm-lang.org/packages/NoRedInk/elm-json-decode-pipeline/latest/
- https://package.elm-lang.org/packages/webbhuset/elm-json-decode/latest/
But wouldn’t it be sweet if one could write this:
userDecoder =
Json.Decode.record
{ name = Json.Decode.string
, age = Json.Decode.int
}
The problem with the above is – what would the type of userDecoder
be? I don’t know. But I do know how to express it in TypeScript:
<T>(
mapping: { [key in keyof T]: Decoder<T[key]> }
) => Decoder<T>
Here’s a very rough and incomplete decoding library in TypeScript:
decoders.d.ts:
export type Result<T> =
| {
tag: "Ok";
value: T;
}
| {
tag: "Err";
message: string;
};
export type Decoder<T> = (value: unknown) => Result<T>;
export const string: Decoder<string>;
export const number: Decoder<number>;
export const field: <T>(key: string, decoder: Decoder<T>) => Decoder<T>;
export const record: <T>(
mapping: { [key in keyof T]: Decoder<T[key]> }
) => Decoder<T>;
decoders.js:
export const string = value =>
typeof value === "string"
? { tag: "Ok", value }
: { tag: "Err", message: `Expected a string, but got: ${typeof value}` };
export const number = value =>
typeof value === "number"
? { tag: "Ok", value }
: { tag: "Err", message: `Expected a number, but got: ${typeof value}` };
export const field = (key, decoder) => value => {
if (typeof value !== "object" || value == null || Array.isArray(value)) {
return {
tag: "Err",
message: `Expected an object, but got: ${typeof value}`,
};
}
return decoder(value[key]);
};
export const record = mapping => value => {
const keys = Object.keys(mapping);
const result = {};
for (let index = 0; index < keys.length; index++) {
const key = keys[index];
const decoder = mapping[key];
const decoded = decoder(value);
if (decoded.tag === "Err") {
return {
tag: "Err",
message: `${key}: ${decoded.message}`,
};
}
result[key] = decoded.value;
}
return { tag: "Ok", value: result };
};
demo.ts:
import { string, number, record, field } from "./decoders";
type User = {
name: string;
age: number;
};
const userDecoder = record({
name: field("name", string),
age: field("age", number),
});
function greet(user: User): string {
return `Hello, ${user.name}!`;
}
function main(): string {
const result = userDecoder({
name: "John",
age: 30,
});
switch (result.tag) {
case "Ok":
return greet(result.value);
case "Err":
return `Failed to decode: ${result.message}`;
}
}
console.log(main());
It works, and typechecks nicely!
Now, why did I implement the above library in JavaScript, adding a separate TypeScript definition file? Because I have no clue how to implement the record
function (which includes the cool { [key in keyof T]: Decoder<T[key]> }
type) in a way that TypeScript approves of. I also have no idea what it would look like in Elm.
My point with all of this is that Elm maybe some time in the future could use this TypeScript example as inspiration to somehow make JSON decoding friendlier. On the other hand, I really appreciate how Elm’s type system is much simpler than TypeScript’s.
Shameless plug: I learned about this when I made version 2.0 of tiny-decoders, a JSON decoding library inspired by Elm for TypeScript and Flow.