Idiomatic way of parsing JSON variants with runtime guarantee

Say I’m calling an api and get back some JSON which can have different forms:

{"ok": true, "data": {"users": [...]}} // success case
{"ok": false, "error_code": "not_authorized", "message": "..."}

Or it has multiple success forms (like parsing an agents output):

{"type": "agent_start"}
{"type": "message_start", "message": {...}}
{"type": "message_end", "message": {...}}

What’s the idiomatic way of defining the return type for a call then? Do you use variants with @tag("type") as @as("agent_start")? Or is there another way?

And how can I check during runtime that the shape actually matches the expected variant? Do I just use sury and redefine the type as a schema or has ReScript built in mechanisms to do that for me?

I’d say Sury or any other encoding/decoding library is the idiomatic way in any programming language, not only ReScript. You don’t want to depend on an API response structure for your domain types anyway.

4 Likes

Yeah, I just thought that maybe rescript has something built in. And is the tagged variant type the way to go to describe the shape?

In many cases a library like sury make sense, but if you wouldn’t like to rely on a library, you could also use for example the built-in json type to parse it.

type startMessage = {
  id: string,
  message: string,
}

type endMessage = {
  id: string,
  message: string,
}

type output = AgentStart | MessageStart(startMessage) | MessageEnd(endMessage)

let parse = (value: JSON.t) => {
  switch value {
  | Object(dict{"type": JSON.String("agent_start")}) => AgentStart
  | Object(dict{
      "type": JSON.String("message_start"),
      "message": JSON.Object(dict{"id": JSON.String(id), "message": JSON.String(message)}),
    }) =>
    MessageStart({id, message})
  | Object(dict{
      "type": JSON.String("message_end"),
      "message": JSON.Object(dict{"id": JSON.String(id), "message": JSON.String(message)}),
    }) =>
    MessageEnd({id, message})
  | _ => panic("invalid output")
  }
}
3 Likes