DEV Community

Discussion on: Domain Modeling with Tagged Unions in GraphQL, ReasonML, and TypeScript

Collapse
 
ksaldana1 profile image
Kevin Saldaña • Edited

Hey there! I'm glad you enjoyed the article and are diving into learning about Reason!

That part of the post is a bit shallow, and I definitely hand-waved over some of the finer details. I thought about diving into the nuances of your question in my article initially, but was hesitant to make the post longer than it already was!

It turns out I was making some assumptions about the relay-runtime and under-the-hood transformations that turned out to be incorrect. I'll walk through what I mean.

Let's say we created a new type on our MessageAuthor union called Suspended, to represent a user that has been temporarily suspended.

union MessageAuthor = User | Guest | Suspended

type Suspended {
  id: ID!
  username: String!
}

type User {
  id: ID!
  name: String!
  dateCreated: String!
  messages: [Message!]!
  role: USER_ROLE!
}

enum USER_ROLE {
  FREE
  PREMIUM
  WHALE
}

type Guest {
  # Placeholder name to query
  placeholder: String!
}

If our GraphQL server updated before we got the chance to update the UI, our Messages query could have authors that the UI does not know how to deal with (eg. authors with the __typename of Suspended). In an ideal world, we have already handled the case for "unexpected" types.

That is where I thought the %other type was coming from. I thought that if Relay ran into any types that it was unaware of (from its own generation), that it would replace the __typename with the literal of %other.... It turns out that is not happening.

ts-error-1

This will happily go through to your functions, which in our example will blow up (because the assertNever function is called).

This is not what is happening in ReasonRelay—it is conforming to the behavior I described ("if I don't know what this __typename is, assign it some arbitrary value").

relay-error-1

This is a bit hard to parse, because of the way BuckleScript compiles down the ReasonML code, but you can mentally translate the number 809179453 to the string of %other or the variant UnmappedUnionMember. So in our code that we already wrote:

[@react.component]
let make = () => {
  let query = Query.use(~variables=(), ());
  <div className=Styles.app>
    {Belt.Array.map(query.messages, message => {
       switch (message.author) {
       | `User(user) =>
         <div className={Styles.message(user.role)}>
           {React.string(user.name ++ ": " ++ message.text)}
         </div>
       | `Guest(guest) =>
         <div>
           {React.string(guest.placeholder ++ ": " ++ message.text)}
         </div>
       | `UnmappedUnionMember => React.null
       }
     })
     ->React.array}
  </div>;
};

We've already handled that case and thought about what we want the UI to do as a result. Another note—that UnmappedUnionMember case is a great place to put some logging or error reporting! Then you can catch that schema change even faster!

When we update our query and schema, the Reason compiler now gives us a helpful error about our missing case. This is very useful if you have lots of utility functions that work against this set of values.

reason-error-4

So it seems that I made some faulty assumptions about what's happening on the TypeScript side—it seems the types are not actually helping you against this case. I want to learn more about what is going on with that before I make any edits to the post. Good catch though!

So that's the first part of your question, but there's a second part that's important as well, and that's why don't we just depend on the default case instead? You are absolutely correct in that it will help us avoid the really bad stuff, like runtime crashes. It's much better to just render nothing in React than crash entirely (though even this is contextual).

The main reason you don't want to depend on the default fallthrough is that you lose a little bit of the power of exhaustive pattern matching. Not all of it, but some.

As an example, let's say we update our premium example to include 1 more tier of role SUPER_WHALES

enum USER_ROLE {
    FREE
    PREMIUM
    WHALE
    SUPER_WHALE
}

If I wrote my premiumColor function to depend on the default case

function premiumColor(role: USER_ROLE) {
  switch (role) {
    case "PREMIUM": {
      return "red";
    }
    case "WHALE": {
      return "blue";
    }
    case "FREE": {
      return "black";
    }
    default: {
      return "black";
    }
  }
}

When I regenerated my schema (that now includes the SUPER_WHALE role), my compiler wouldn't give me any hints about changing requirements. Unknown to me, SUPER_WHALE users now have normal text-colored posts. However if I wrote it with the exhaustive check in the default case:

function premiumColor(role: USER_ROLE) {
  switch (role) {
    case "PREMIUM": {
      return "red";
    }
    case "WHALE": {
      return "blue";
    }
    case "FREE": {
      return "black";
    }
    case "%future added value": {
      return "black";
    }
    default: {
      assertNever(role);
    }
  }
}

I'd get a compiler error when my schema picked up the new role.

ts-error-2

This would help me pick up that requirement faster as a developer. Now, I'd still have the same issue of SUPER_WHALE users having normal posts, but I have now structured in my code in a way that let's the compiler help me recognize those gaps.

The ReasonML doc sections on pattern matching has a tidbit that goes into the reasoning a bit more.

Do not abuse the fall-through _ case too much. This prevents the compiler from telling you that you've forgotten to cover a case (exhaustiveness check), which would be especially helpful after a refactoring where you add a new case to a variant. Try only using _ against infinite possibilities, e.g. string, int, etc.

So ultimately it's about helping the compiler help you!

Hope that help answers your question! I will look more into what's going on with Relay and its %other type on the TS side, because from the way I understand it now, it seems to just be a footgun.

Collapse
 
yaldram profile image
Arsalan Ahmed Yaldram

Thanks a lot for such a detailed explanation. It is people like you who help us understand things better. Love from India Sir.