How to pass the data on its own type directly from client to server?

The client side and server side are both written in Rescript only.

However, if I need to send a data with a complex type shape(like variants inside variants inside record) from client to server, I have to encode it to json at first on client, and decode the json to its own type on server.

Writing encoder-decoder is a big mess.

Is there a direct way to pass the data on its own type from client to server?

I use rescript-struct for endocing-decoding. It’s still needed to make sure that the data on the backend is valid. Here’s what we have for Next.js api. It’s kind of similar to the trpc.

// ApiHandler.res
type t<'body, 'data> = {
  method: Next.Handler.method,
  bodyStruct: S.t<'body>,
  dataStruct: S.t<'data>,
  route: string,
}

let register = (handler, invoke): Next.Handler.t => {
  let exceptionResponse = message =>
    {
      "message": message,
    }->(Obj.magic: {"message": string} => Json.t)

  async (req, res) => {
    if req.method !== handler.method {
      res
      ->Next.Handler.Res.status(405)
      ->Next.Handler.Res.setHeader("Allow", (handler.method :> string))
      ->Next.Handler.Res.json(exceptionResponse("Method Not Allowed"))
    } else {
      switch req.body->S.parseAnyWith(handler.bodyStruct) {
      | Ok(body) =>
        let data = await invoke(body)
        switch data->S.serializeWith(handler.dataStruct) {
        | Ok(json) => res->Next.Handler.Res.status(200)->Next.Handler.Res.json(json)
        | Error(error) =>
          res
          ->Next.Handler.Res.status(400)
          ->Next.Handler.Res.json(error->S.Error.toString->exceptionResponse)
        }

      | Error(error) =>
        res
        ->Next.Handler.Res.status(400)
        ->Next.Handler.Res.json(error->S.Error.toString->exceptionResponse)
      }
    }
  }
}

let toTask = (handler, body) => {
  let path = "/api" ++ handler.route
  Task.make((~resolve, ~reject) => {
    let ended = ref(false)
    switch body->S.serializeToJsonWith(handler.bodyStruct) {
    | Error(e) => reject(`Failed to serialize input to JSON. ${e->S.Error.toString}`)
    | Ok(bodyJsonString) =>
      Fetch.fetch(
        path,
        {
          method: handler.method->(Obj.magic: Next.Handler.method => Fetch.method),
          headers: Fetch.Headers.fromObject({
            "Content-Type": "application/json",
          }),
          body: bodyJsonString->Fetch.Body.string,
        },
      )
      ->Promise.then(Fetch.Response.json)
      ->Promise.thenResolve(response =>
        if !ended.contents {
          switch response->S.parseWith(handler.dataStruct) {
          | Ok(data) => resolve(data)
          | Error(e) => reject(`Failed to parse data from response. ${e->S.Error.toString}`)
          }
        }
      )
      ->Promise.catch(exn => {
        if !ended.contents {
          switch exn {
          | Exn.Error(error) => reject(error->Exn.message->Option.getWithDefault("Unknown error"))
          | exn => raise(exn)
          }
        }
        Promise.resolve()
      })
      ->ignore
    }
    Some(() => ended := true)
  })
}


module Login = {
  type body = {
    email: Email.t,
    password: Password.t,
  }
  type ok = {user: User.t}
  type error =
    | InvalidEmail
    | UserNotFound
    | UnknownError
  type data = result<ok, error>

  let make = () => {
    method: #POST,
    route: "/login",
    bodyStruct: S.object(o => {
      email: o->S.field("email", Struct.email),
      password: o->S.field("password", Struct.password),
    }),
    dataStruct: S.union([
      S.literal(Error(InvalidEmail)),
      S.literal(Error(UserNotFound)),
      S.literal(Error(UnknownError)),
      S.object(o => Ok({
        user: o->S.field("user", Struct.user),
      })),
    ]),
  }
}
// LoginHandler.res

let handler = ApiHandler.Login.make()->ApiHandler.register(async ({
  email,
  password
}): ApiHandler.Login.data => {
  // Do stuff
  Ok({
     user: user,
  })
}
// Somewhere on the client

let loginHandler = ApiHandler.Login.make()

@react.component
let make = () => {
  <button onClick={_ => {
    let loginTask = loginHandler->ApiHandler.toTask({
          email,
          password,
        })
    loginTask->Task.fork(~onResolve=Console.log,~onReject=Console.log)->ignore
  }} />
}
1 Like

What are the challenges with the complex shapes? I’d rather imagine undefined values to be a problem, since serializing those causes errors due to undefined not being a valid JSON value.

For variants, there will be big improvements for the runtime mapping as described here: Better interop with customizable variants | ReScript Blog. This helped me a lot to not care about encoding / decoding because I’d just assert incoming server data to be the right data shape.