Inspiration: Cool decoder type from TypeScript

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:

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.

4 Likes

I have a feeling this is inexpressible in Elm - It seems “enumerable record-fields” would be required to accomplish this API. Probably extensions to the type-system too. Is that added complexity worth the convenience of this beautiful API you’re describing? I dunno.

slightly off-topic

Elm has an incredibly tight language-design IMO, and while I absolutely love this, it also means that a lot of discussion around problems will come down to “language design” and people requesting new features at that level.

I think if we (the community) focus on building great tooling at the package-level, the language-team will have a better idea what kinds of features will be worthwhile at the language-level.

In other words, if we build enough cool stuff with clear limitations and push those ideas as far as we can within the current framework, maybe the language will evolve to better support those ideas later?

That’d be my guess :sunny:

2 Likes

So just a few days ago this was published:

https://package.elm-lang.org/packages/eike/json-decode-complete/latest

I did not have the time yet to check it out, but from the description, it seems to be what you’re looking for.

Edit:

Just noticed that this exact package is currently being discussed in

1 Like

That’s a cool package! However, it is not related to this discussion. My hypothetical Decode.record function could be strict/complete, or there could be a separate Decode.recordStrict/Decode.recordComplete.

json-decode-complete relies on using a record type alias as constructor which, just like standard Json.Decode and elm-json-decode-pipeline, relies on you not messing up the order of fields with the same type.

My idea is to explore being able to write record decoders in the most straight-forward way: as a record.

I see, well that’s a pity. But now I can tell you for sure such a decoder does not exist as from 0.19 onward, for one reason:

before 0.19 a setter \a -> {a|key = value} had the type record -> {record|key:a} and thus a pipe

{}
|> \a -> {a|l_1=v_1}
|> \a -> {a|l_2=v_2}
--> {l_1=v_1,l_2=v_2}

was valid Elm code (and the only way how a package like the one you describe would have been possible)

but from 0.19 onwards the setter \a -> {a|key=value} has the type {record|key=value}->{record|key=value} and therefore the only valid way to say anything about records is by using constructors.

So in conclusion: While I think that your suggestion is a nice idea, is not something that the language supports (or will support in the future).

Yeah, I figured as much too.

Who knows? Do note here that my suggestion isn’t “Please add Decode.record like I describe” but rather “If you ever do larger changes in the language to make JSON decoding nicer, please keep this type feature from TypeScript somewhere in the back of your head!”

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.