Handling nullability and errors
Make your queries even more typesafe
Introduction
This section is a high level description of nullability and errors in GraphQL and the problem it causes for app developers. For the proposed solution, skip directly to the @semanticNonNull section.
GraphQL does not have a Result type. If a field errors, it is set to null in the JSON response and an error is added to the errors array.
From the schema alone, it is impossible to tell if null is a valid business value or only happens for errors:
1type User {
2 id: ID!
3 # Should the UI deal with user without a name here? It's impossible to tell.
4 name: String
5 avatarUrl: String
6}The GraphQL best practices recommend making fields nullable by default to account for errors.
From graphql.org:
In a GraphQL type system, every field is nullable by default. This is because there are many things that can go awry in a networked service backed by databases and other services.
For an example, the following query:
1query GetUser {
2 user {
3 id
4 name
5 avatarUrl
6 }
7}receives a response like so in the case of an error:
1{
2 "data": {
3 "user": {
4 "id": "1001",
5 "name": null,
6 "avatarUrl": "https://example.com/pic.png"
7 }
8 },
9 "errors": [
10 {
11 "message": "Cannot resolve user.name",
12 "path": ["user", "name"]
13 }
14 ]
15}This nullable default has one major drawback for frontend developers. It requires to carefully check every field in your UI code.
Sometimes it's not clear how to handle the different cases:
1@Composable
2fun User(user: GetUserQuery.User) {
3 if (user.name != null) {
4 Text(text = user.name)
5 } else {
6 // What to do here?
7 // Is it an error?
8 // Is it a true null?
9 // Should I display a placeholder? an error? hide the view?
10 }
11}When there are a lot of fields, handling the null case on every one of them becomes really tedious.
Apollo Kotlin offers nullability directives to deal with this situation:
@semanticNonNull(schema directive)@catch(client directive)
These tools change the GraphQL default from "handle every field error" to "opt-in the errors you want to handle".
Enabling error aware parsing
To make the Apollo generated parsers aware of errors, import the nullability directives using the @link directive:
1extend schema @link(
2 url: "https://specs.apollo.dev/nullability/v0.4",
3 import: ["@semanticNonNull", "@semanticNonNullField", "@catch", "CatchTo", "@catchByDefault"]
4)And define the default behavior when an error happens.
You may catch the error and expose it as a FieldResult:
1# Catch the error and expose it as a FieldResult<T> in the generated models.
2extend schema @catchByDefault(to: RESULT)or re-throw the error:
1# Re-throw the error. If no parent field catches it, `response.exception` contains an instance of `ApolloGraphQLException`.
2extend schema @catchByDefault(to: THROW)or coerce the error to null, like the current GraphQL default:
1# Coerce the error to null. The caller must read `response.errors` to disambiguate a null vs error field.
2extend schema @catchByDefault(to: NULL)Adding @catchByDefault(to: NULL) is a no-op for codegen that unlocks using @catch in your operations.
Because errors can never happen on non-null fields (String! and others), @catchByDefault only influences the nullable fields in your schema.
Some of those fields are only nullable for error reasons. For those cases, Apollo Kotlin supports @semanticNonNull.
@semanticNonNull
@semanticNonNull introduces a new type in the GraphQL type system.
A @semanticNonNull type can never be null except if there is an error in the errors array.
Use it in your schema:
1type User {
2 id: ID!
3 # name is never null unless there is an error
4 name: String @semanticNonNull
5 # avatarUrl may be null even if there is no error. In that case the UI should be prepared to display a placeholder.
6 avatarUrl: String
7}@semanticNonNull is a directive so that it can be introduced without breaking the current GraphQL tooling but the ultimate goal is to introduce new syntax. See the nullability working group discussion for more details.For fields of List type, @semanticNonNull applies only to the first level. If you need to apply it to a given level, use the levels argument:
1type User {
2 # adminRoles may be null if the user is not an admin
3 # if the user is an admin, adminRoles[i] is never null unless there is also an error
4 adminRoles: [AdminRole] @semanticNonNull(levels: [1])
5}With @semanticNonNull, a frontend developer knows that a given field will never be null in regular operation and can therefore act accordingly. The Apollo Kotlin codegen generates @semanticNonNull fields as non-null Kotlin properties. No need to guess anymore!
Ideally, your backend team annotates their schema with @semanticNonNull directives so that different frontend teams can benefit from the new type information.
Sometimes this process takes time.
For these situations, you can extend your schema by using @semanticNonNullField in your extra.graphqls file:
1# Same effect as above but works as a schema extensions
2extend type User @semanticNonNullField(name: "name")You can later share that file with your backend team and double check that your interpretation of the types is the correct one.
@catch
While @semanticNonNull is a server directive that describes your data, @catch is a client directive that defines how to handle errors.
@catch allows to:
handle errors as
FieldResult<T>, getting access to the colocated error.throw the error and let another parent field handle it or bubble up to
data == null.coerce the error to
null(current GraphQL default).
For fields of List type, @catch applies only to the first level. If you need to apply it to a given level, use the levels argument:
1query GetUser {
2 user {
3 # map friends[i] to FieldResult
4 friends @catch(to: RESULT, levels: [1])
5 }
6}Colocate errors
To get colocated errors, use @catch(to: RESULT):
1query GetUser {
2 user {
3 id
4 # map name to FieldResult<String> instead of stopping parsing
5 name @catch(to: RESULT)
6 }
7}The above query generates the following Kotlin code:
1class User(
2 val id: String,
3 // note how String is not nullable. This is because name
4 // was marked `@semanticNonNull` in the previous section.
5 val name: FieldResult<String>,
6)Use getOrNull() to get the value:
1println(user.name.getOrNull()) // "Luke Skywalker"
2// or you can also decide to throw on error
3println(user.name.getOrThrow())And graphQLErrorOrNull() to get the colocated error:
1println(user.name.graphQLErrorOrNull()) // "Cannot resolve user.name"Throw errors
To throw errors, use @catch(to: THROW):
1query GetUser {
2 user {
3 id
4 # throw any error
5 name @catch(to: THROW)
6 }
7}The above query generates the following Kotlin code:
1class User(
2 val id: String,
3 val name: String,
4)ApolloResponse.exception.Coerce errors to null
To coerce errors to null (current GraphQL default), use @catch(to: NULL):
1query GetUser {
2 user {
3 id
4 # coerce errors to null
5 name @catch(to: NULL)
6 }
7}The above query generates the following Kotlin code:
1class User(
2 val id: String,
3 // Note how name is nullable again despite being marked
4 // @semanticNonNull in the schema
5 val name: String?,
6)ApolloResponse.exception.Migrate to semantic nullability
Semantic nullability is the most useful for schemas that are nullable by default. These are the schemas that require "handling every field error".
In order to change that default to "opt-in the errors you want to handle", you can use the following approach:
import the nullability directives.
Default to coercing to null:
extend schema @catchByDefault(to: NULL). This is a no-op to start exploring the directives.Add
@catchto individual fields, get more comfortable with how it works.When ready to do the big switch, change to
extend schema catchByDefault(to: THROW)and (at the same time) addquery GetFoo @catchByDefault(to: NULL) {}on all operations/fragments (this is a no-op).From this moment on, new queries written throw on errors by default.
Remove
query GetFoo @catchByDefault(to: NULL) {}progressively.
Migrate from @nonnull
If you were using @nonnull before, you can now use @semanticNonNull.
@semanticNonNull, coupled with @catch is more flexible and also more in line with other frameworks.
Catch to NULL by default:
1extend schema @link(
2 url: "https://specs.apollo.dev/nullability/v0.4",
3 import: ["@semanticNonNull", "@semanticNonNullField", "@catch", "CatchTo", "@catchByDefault"]
4)
5
6extend schema @catchByDefault(to: NULL)Replace @nonnull with @semanticNonNullField:
1# Replace
2extend type Foo @nonnull(fields: "bar")
3
4# With
5extend type Foo @semanticNonNullField(name: "bar")In your queries, use @catch(to: THROW) to generate those fields as non-nullable:
1# Add `@catch(to: THROW)`
2query GetFoo {
3 foo @catch(to: THROW)
4}For usages in executable documents
Because nullability is a schema concern, @semanticNonNull cannot be used in executable documents. Instead, define your fields as @semanticNonNull:
1# Replace
2query GetFoo {
3 foo @nonnull
4}
5
6# With
7query GetFoo {
8 foo @catch(to: THROW)
9}
10
11# and make foo `@semanticNonNull` in your schema
12extends type Query @semanticNonNullField(name: "foo")