Using a scoped polymorphic type for react-router bindings to loader function

I’ve just also run into a similar problem as Error: Field value has a type which is less general than the (defined) scoped polymorphic type writing bindings for react-router v6:

This type definition for a RouteObject:

type rec t = {
  caseSensitive?: bool,
  children?: array<t>,
  element?: React.element,
  index?: bool,
  path?: string,
  loader?: 'data. unit => promise<'data>,
  action?: unknown, // TODO
  errorElement?: React.element,
  hydrateFallbackElement?: React.element,
  @as("Component") component?: React.component<{}>,
  @as("ErrorBoundary") errorBoundary?: React.component<{}>,
  // handle?: unknown
  shouldRevalidate?: bool,
  @as("lazy") lazy_?: unit => promise<unknown>,
}

When actually attempting to use it gives this type error

  60 │   caseSensitive: true,
  61 │   element: <Settings />,
  62 │   loader: async (type data, ()) => {
  63 │     let response = await Fetch.fetch("/api/settings", {method: #GET})
   . │ ...
  66 │     json
  67 │   },
  68 │ }
  69 │ 

  This has type: unit => promise<Js.Json.t>
  But it's expected to have type: 'data. unit => promise<'data>

Now I could reach for the GADT-style of binding the route objects, and in some respects that makes sense for the union type. But does anyone know how you could type-annotate this to use this feature? I can imagine a good number of Javascript APIs that’d become a lot less verbose to use with this–presuming you don’t have to use @send bindings to get polymorphic behavior on a record type with a function.

Unfortunately this can’t work, why don’t you just make RouteObject polymorphic?

The target data seems dependent on the Settings component and not a generalized type.

And these original types are actually polymorphic at the implementation level. Making the polymorphism explicit or restricting it to be monomorphic in compile time wins in e2e quality, safety, and performance.

Ah, so I left out some critical context here. RouteObjects are made into an array (tree) that is then passed to the create*Router functions:

let routes = [
  {
    RouteObject.id: "root",
    path: "/",
    caseSensitive: true,
    element: <Layout />,
    errorElement: <ErrorRoute />,
    children: [
      {
        id: "dashboard",
        path: "dashboard",
        element: <Dashboard />,
        caseSensitive: true,
      },
      {
        id: "settings",
        path: "settings",
        caseSensitive: true,
        element: <Settings />,
        errorElement: <ErrorRoute />,
        loader: async () => {
          let response = await Fetch.fetch("/api/settings", {method: #GET})

          if (!Fetch.Response.ok(response)) {
            Error.make("Not ok!")->Error.raise
          }

          switch await Fetch.Response.json(response) {
            | data => data
            | exception _e => Error.make("Json parsing error")->Error.raise
          }
        },
      },
      {
        id: "error",
        path: "error",
        caseSensitive: true,
        element: <article>
          <h1> {React.string("No Error Page")} </h1>
        </article>,
        errorElement: <ErrorRoute />,
        loader: () => Error.make("rejected")->Error.toException->Promise.reject,
      },
    ],
  },
]

This will compile if the values are all left as JSON.t, but then it precludes having mixed response types in the APIs you call. There is also the option of typing all the RouteObject.loader properties to return a Response

I don’t much like either of these options, because I’d like to make the react-router hook useRouteLoaderData be able return a typed response for the route with the specific API response types. I feel I am pretty close to this using the GADT approach I tried after posting last night:

module RouteObject = {
  type rec route<'data, 'action, 'handle> = {
    id: string,
    caseSensitive?: bool,
    children?: array<t_raw>,
    element?: React.element,
    index?: bool,
    path?: string,
    action?: actionArgs => promise<'action>,
    loader?: loaderArgs => promise<'data>,
    errorElement?: React.element,
    hydrateFallbackElement?: React.element,
    handle?: 'handle,
    shouldRevalidate?: bool,
    @as("lazy") lazy_?: unit => promise<unknown>,
  }
  and t_raw = Route(route<'data, 'action, 'handle>): t_raw
}

let routes = [
  Route({
    id: "root",
    path: "/",
    children: [
      Route({
        id: "settings",
        path: "settings",
        loader: ({request: _, params: _}) =>
          Fetch.fetch("/api/settings", {method: #GET})
          ->Promise.then(response => response->Fetch.Response.json)
          ->Promise.then(json => JSON.Decode.string(json)->Promise.resolve),
      }),
      Route({
        id: "profile",
        path: "profile",
        loader: ({request: _, params: _}) =>
          Fetch.fetch("/api/profile", {method: #GET})
          ->Promise.then(response => response->Fetch.Response.json)
          ->Promise.then(json => JSON.Decode.bool(json)->Promise.resolve),
      }),
    ],
  }),
]

@module("react-router-dom")
external useRouteLoaderData: (@ignore RouteObject.t<'loaderData, _, _>, string) => 'loaderData = "useRouteLoaderData"

However the issue here is that @unboxed can’t be applied to just t_raw in the mutually recursive type definition, so it ends up being tagged in the compiled form, which wouldn’t work at runtime.

Yeah, I’d say you are right. The issue fundamentally is that it’s not a very rescript-y API I’m trying to bind to. I would rather trade off type safety in the route configuration, which isn’t actually used for much aside from creating the router instance via a binding, in order to get more type safety where consumers would actually use the data, via useRouteLoaderData, if at all possible.

I usually use a module type abstraction for it. Something like:

module type Entry = {
  type loaderData
  let element: unit => React.element
}

It adds overhead but it’s just wrap/unwrap. And I guess it doesn’t invalidate memoization in the specific usecase

1 Like

If you don’t need to access those polymorphic functions from rescript, this would work:

module RouteObject = {
  @unboxed type rec t = Route({
    id: string,
    caseSensitive?: bool,
    children?: array<t>,
    element?: React.element,
    index?: bool,
    path?: string,
    action?: actionArgs => promise<'action>,
    loader?: loaderArgs => promise<'data>,
    errorElement?: React.element,
    hydrateFallbackElement?: React.element,
    handle?: 'handle,
    shouldRevalidate?: bool,
    @as("lazy") lazy_?: unit => promise<unknown>,
  }): t
}

Otherwise just make it polymorphic and create a a monomorphic abstract type with a identity function to go between the polymorphic and monomorphic types and use the monomorphic type where needed, like in arrays.

1 Like

I was working on some bindings for it and used a module function to make a loader:

1 Like