Return union types in ReScript

Inspired by this article I've decided to share an approach on returning union types in ReScript. The case in point is exporting a getServerSideProps function from a Next.js page, enabling us to fetch the props for pre-rendering the page server side. This example function returns only two kinds of objects, one for the props and another for the redirect (we will not handle the not found case). Note that in the ReScript implementation we will also be using an object for the return value mainly for convenience. Since it doesn't require a type declaration, the object type makes it easy to return the runtime representation of our desired object in JavaScript.

Let's see the TypeScript implementation first to get a better idea of what we're trying to achieve. The type declaration of the getServerSideProps function reveals no surprise, its return type is a union type which can either return props, a redirect or notFound.

// .ts
 export type GetServerSidePropsResult<P> =
   | { props: P }
   | { redirect: Redirect }
   | { notFound: true }

 export type GetServerSideProps<
   P extends { [key: string]: any } = { [key: string]: any },
   Q extends ParsedUrlQuery = ParsedUrlQuery
 > = (
   context: GetServerSidePropsContext<Q>
 ) => Promise<GetServerSidePropsResult<P>>

Here is our complete TypeScript example:

// Page.ts
 import { GetServerSideProps, NextPage } from "next"

 type User = {
   name: string
   age: number
 }

 type Props = { user: User }

 export const getServerSideProps: GetServerSideProps<Props> = async (
   context,
 ) => {
   const response = await fetch(`https://.../user`)
   const user: User = await response.json()

   if (!user) {
     return {
       redirect: {
         destination: "/",
         permanent: false,
       },
     }
   }

   return {
     props: { user },
   }
 }

 const Page: NextPage<Props> = ({ user: { name, age } }) => {
   return (
     <div>
       <p>User name: {name}</p>
       <p>Age: {age}</p>
     </div>
   )
 }

 export default Page

Working with the union type looks effortless in TypeScript but is it the same for the ReScript counterpart? We'll start by namespacing the function's type definition into a separate module to keep things organized. Inside the module we also declare the type for the context argument this function takes.

module GetServerSideProps = {
   module Req = {
     type t
   }

   module Res = {
     type t
   }

   type context<'props, 'params, 'previewData> = {
     params: 'params,
     preview: option<bool>,
     previewData: Js.Nullable.t<'previewData>,
     query: Js.Dict.t<string>,
     req: Req.t,
     res: Res.t,
   }

   // The type of the `getServerSideProps` function
   type t<'props, 'params, 'previewData> = context<'props, 'params, 'previewData> => Js.Promise.t<{
     "props": 'props,
   }>
 }

For convenience we choose to abstract req and res properties on the context record. We declare them as opaque types because we’re not sure about their runtime representation and we actually don’t care about it. Currently our function returns a single object type with props.

We'll group the union type definition into another nested module named Return and we'll provide a module signature to expose two methods which are responsible for returning the appropriate object kind with either props or redirection. Everything else is left as an implementation detail. Here is how the Return module looks like.

module Return: {
   type rec t<'props>

   let inject: (~props: 'props) => t<'props>

   let redirect: (~destination: string, ~permanent: bool, unit) => t<'props>
 } = {
   @unboxed type rec t<'props> = Any('value): t<'props>

   let inject = (~props) => Any({ "props": props })

   let redirect = (~destination, ~permanent, ()) => Any({
     "redirect": { 
       "destination": destination, 
       "permanent": permanent
     },
   })
 }

Checking the module signature we notice again an opaque type t<'props> to hide the underlying type of the Return module as an implementation detail. This type references itself so we need to define it as a recursive type rec otherwise we would get a compiler error.

Inside the Return module, t is defined as a variant Any with some payload. We are also making use of the @unboxed attribute which strips out the variant constructor and makes its runtime representation equal to the underlying value. These two combined enable returning at runtime the two objects expected from geServerSideProps.
Now we can update the function's type definition to return a value of type Return.t<'props>.

type t<'props, 'params, 'previewData> = context<'props, 'params, 'previewData> => Js.Promise.t<
   Return.t<'props>,
 >

We are now ready to implement the getServerSideProps function inside the Page file. We destructure inject and redirect functions from the Return module and call them to return the desired object - inject to "inject" the props into the page and redirect to redirect to the main page when failing to load the props.

// Page.res
 module GetServerSideProps = {
   module Req = {
     type t
   }

   module Res = {
     type t
   }

   type context<'props, 'params, 'previewData> = {
     params: 'params,
     preview: option<bool>,
     previewData: Js.Nullable.t<'previewData>,
     query: Js.Dict.t<string>,
     req: Req.t,
     res: Res.t,
   }

   module Return: {
     type rec t<'props>

     let inject: (~props: 'props) => t<'props>

     let redirect: (~destination: string, ~permanent: bool, unit) => t<'props>
   } = {
     @unboxed type rec t<'props> = Any('value): t<'props>

     let inject = (~props) => Any({"props": props})

     let redirect = (~destination, ~permanent, ()) => Any({
       "redirect": {"destination": destination, "permanent": permanent},
     })
   }

   type t<'props, 'params, 'previewData> = context<'props, 'params, 'previewData> => Js.Promise.t<
     Return.t<'props>,
   >
 }

 type user = {
   name: string,
   age: int,
 }

 type props = {user: user}

 let getServerSideProps: GetServerSideProps.t<_, _, _> = _context => {
   let {inject, redirect} = module(GetServerSideProps.Return)

   let getData = () => {
     // fetch the data from somewhere
     Js.Promise.resolve({name: "John", age: 30})
   }

   getData()->Js.Promise.then_(user => {
     inject(~props={user: user})->Js.Promise.resolve
   }, _)->Js.Promise.catch(_error => {
     redirect(~destination="/", ~permanent=true, ())->Js.Promise.resolve
   }, _)
 }

 @react.component
 let default = (~user: user) => {
   let {name, age} = user

   <div>
     <p> {`User name: ${name}`->React.string} </p>
     <p> {`Age: ${age->Js.Int.toString}`->React.string} </p>
   </div>
 }

Hopefully this example helps you get a grasp on union types in ReScript. It may require a bit more effort to master the topic as a newcomer to the language but I believe this is by no means impossible to achieve. It's definitely worth it.

33