/

hasura-header-illustration

Nulls in GraphQL: Cheatsheet

Nullability in GraphQL is a controversial topic. Some say constant null checks are a nuisance, while others err on the side of paranoia. I say, UFO's are real. But, that's not the point. In this post, you'll hear both sides of the discussion, find a cheatsheet you can refer to later, and read about different approaches to nulls and errors by Apollo and Relay.

Nullability and error handling in GraphQL

So what's all the ruckus about? It all starts with GraphQL's unconventional approach to defaults and error handling.

In other typed languages, like TypeScript, Flow, Rust, or Haskell, all types are non-nullable by default, and you opt into nullability. But in GraphQL, all types are nullable by default, and you opt into non-nullability. In fact, Non-Null is a type that wraps your original type when you opt in.

TypeScript:

interface User {
  name?: string // opting into optional name
  name: string | null // opting into nullable name
}

GraphQL:

type User {
  name: String! // opting into non-nullable name
}

As if that's not enough fodder for DRAMZ, GraphQL breaks with the REST convention of relying on HTTP status codes for error handling. Even if there's an error in the GraphQL layer while executing your request, the response is still  200 OK 😲

Then how does the client know about errors? This is where nulls become relevant. The server returns a root field called errors, from which the client can extract them, and a root field called data that has all the requested fields. To top it off, any fields with errors have the value null.

All of this to say: all types in GraphQL are nullable by default in case of an error. GraphQL doesn't let a small hiccup get in the way of our data fetching: when there's an error with a field, we still get partial data.

We'll debate the pros and cons in a bit, including different approaches by Apollo and Relay. But first, the promised cheatsheet...

Nulls in the query

A GraphQL query can have fields and inputs, with or without variables.

Fields are always optional, whether nullable or not.

type User {
  name: String! // non-null
  age: Int // nullable
}
{
  user {
    name // optional
    age // optional
  }
}

In inputs, such as field arguments, nullable types are always optional and non‐null types are always required.

type User {
  // nullable filter input, non-null size input
  imageUrl(filter: String, size: String!): String
}
{
  user {
    imageUrl // not valid
    imageUrl(size: null)  // not valid
    imageUrl(size: 'med') // valid
    imageUrl(filter: null, size: 'med') // valid
    imageUrl(filter: 'juno', size: 'med') // valid
  }
}

In addition, a variable of a nullable type cannot be provided to a non‐null argument.

query user($imageSize: String) { // nullable variable
  imageUrl(size: $imageSize) // field with non-null argument
}

Nulls in the response

If you get a null value for a nullable field from the server, there are four possibilities:

  1. The value is actually null or absent, e.g. user doesn't exist
  2. The value is hidden, e.g. user has blocked you (permission error)
  3. There was another error fetching the value
  4. An error or null value has bubbled up from a Non-Null child

Wait, what was that about bubbling up?

The GraphQL spec says that a null result on a Non-Null type bubbles up to the next nullable parent. If this bubbling never stops because everything is of Non-Null type, then the root data field is null.

Let's say name is nullable, but the resolver returns an error while fetching it:

data: { 
  user: { 
    name: null,
    age: 25,
    // other fields on user
 }
},
errors: [
  { 
    message: "Name could not be fetched",
    // ...
  }
]

If name is non-nullable and user is nullable, this is what happens instead:

data: { 
  user: null
},
errors: [
  { 
    message: "Name could not be fetched",
    // ...
  }
]

When fields are nullable, as in the first example, you still get partial data: even if you don't have the name, you can still show the age and other fields. When you mark fields as Non-Null, as in the second example, you forfeit your right to partial data.

How can I know the true meaning of null?

The four possibilities of null leave us with three questions:

  1. Do we have an actual absent value or an error?
  2. If it's an error, which field is it on?
  3. What kind of error is it?

And the answers lie in the errors list returned in the response.

If an error is thrown while resolving a field, it should be treated as though the field returned null, and an error must be added to the errors list in the response.
- GraphQL Spec

Anatomy of the errors list

The errors list must include message, locations, and path entries, with an optional extensions entry.

"errors": [
  {
    // error message from the resolver
    "message": "Name for user with ID 42 could not be fetched.",
    
    // map to the field in the GraphQL document
    "locations": [ { "line": 3, "column": 5 } ],
    
    // path to the field
    "path": [ "user", 0, "name" ],
    
    // optional map with additional info
    "extensions": {
      code: PERMISSION_ERROR,
      weatherOutside: 'weather'
    }
  }
]

The path entry answers our first two questions: whether the null result is intentional or due to an error, and what field it's on. The extensions entry answers our third question by allowing us to specify the type of error, and anything else we can possibly divine: the time of day when it happened, the weather outside, etc.

Paul Rudd & Jason Segel in "Forgetting Sarah Marshall"

In the case of a  Non-Null field, the path entry still specifies the correct source of the error, even when the data returned points to the parent as the troublemaker.

data: { 
  user: null
},
errors: [
  { 
    message: "Name could not be fetched",
    path: [ "user", 0, "name" ]
    // ...
  },
  // ...
]

Pros & cons

Now that we've got the cheatsheet down, let's talk about the pros and cons of nullable vs Non-Null types.

Benefits of nullable types

We've already covered one major benefit:

  • When the HTTP status code is 200 OK, we're able to get partial data from the server, despite errors on specific fields. But we still need a way to tell if something went wrong, which we can achieve with a null value on the erroneous field, along with an errors list (Btw, we'll discuss another solution to this – returning an Error type instead of null – in the Relay section below).

Other benefits:

  • Privacy when you want to obfuscate the reasons for null. Maybe you don't want the client to know whether you got a null on user because the user has blocked you or because the user simply doesn't exist. If you're a ghoster, this is the way to go.
  • If you're serving different clients from a single schema, nullable fields are more reliable, and easier to evolve. For example, if you remove a Non-Null field from the schema, a client that you have no control ever may break when it receives null for that field.
  • Coding defensively on the client side. Null check all the things!
if (user && user.location) return user.location.city;

Benefits of Non-Null types

If you find null checks cumbersome,  Non-Null is the type for you. Why?

  • You get guaranteed values. If you know that location is non-nullable, you can just do this:
if (user) return user.location.city;

If location returns null it will bubble up to user, and user.location.city will never execute. location is guaranteed to never be null, so you never need a null check on it.

  • You can combine Non-Null types with query type generation to make your code even more predictable. For example, with TypeScript, the generated code for a users query can be:
type GetUsersQuery = {
  users: Array<{
    __typename: "User",
    name: string // if Non-Null in schema
    name: string | null // if nullable in schema
  }
};
  • Easy error detection. If you get a null value for a non-nullable field, you know it's because of an error, since it can't be a legitimate absent value.

So how should I design my schema?

Though this is a hotly debated topic, the general recommendation is to make everything nullable except when it doesn't make sense, e.g. getUserById(id: ID!), or the value is guaranteed to exist, e.g. a primary key like id. But you can weigh the pros and cons above, and decide for yourself! (If you do, please let me know in the comments).

Different approaches by GraphQL clients

Depending on what GraphQL client you're using, you may be able to pick your strategy on a per-query basis, or come up with a different solution to error handling altogether.

Apollo: Everything is possible

When it comes to error handling, Apollo gives you options. Three options, in fact.

  • none (default): Treat GraphQL errors like network errors, ignore data
  • ignore: Get data, ignore errors
  • all: Get both data & errors

The all option matches the GraphQL spec, and will allow you to get partial data in case of an error. You can set the errorPolicy on a per-query basis, like so:

const { loading, error, data } = useQuery(MY_QUERY, { errorPolicy: 'all' });

Relay: All is good all the time

Relay is all about living the good life. A little GraphQL error here and there isn't enough to mess with Relay's zen thing.

That's right. Relay ignores GraphQL errors unless:

  • the fetch function provided to the Relay Network throws or returns an Error
  • the top-level data field isn't returned in the response

The Relay docs recommend modeling errors in your schema rather than returning null.

type Error {
  message: String!
}

type User {
  name: String | Error
}

For a detailed breakdown of how you might model this in your schema, check out this excellent blog post by Laurin Quast.

Error handling: GraphQL spec vs Apollo vs Relay

Conclusion

I hope this article has helped you master nulls in GraphQL. We covered:

  • GraphQL's approach to nullability and error handling
  • What nulls mean in GraphQL queries and responses
  • Pros & cons of nullable and Non-Null types
  • Different approaches to nulls and error handling by Apollo and Relay

Where do you stand in this discussion? What has been your experience with nullability? Let us know on Twitter or in the comments below!

Further Reading

Blog
15 May, 2020
Email
Subscribe to stay up-to-date on all things Hasura. One newsletter, once a month.
Loading...
v3-pattern
Accelerate development and data access with radically reduced complexity.