May 29, 2023
I write a series of posts about GraphQL API design. Find the full list here!
Prefer to expose fields that are optional instead of required. In case of an error, GraphQL will only nullify a branch of the data tree but the rest of the data will still render properly.
Error handling in GraphQL works as follows:
This means that using optional fields will make it easier for your clients to gracefully handle errors and maybe display partial data. Using required fields will prevent them from displaying anything.
GraphQL APIs are usually designed to be used by multiple types of clients and I usually think it's best to stay flexible and let each of them decide what they can and cannot display.
Imagine the following schema:
type Query {
transaction(id: ID!): Transaction
}
type Transaction {
id: ID!
amount: Int!
sender: Person!
}
type Person {
name: String!
avatar_url: String!
}
We could easily imagine a client that would display the details of a transaction. They may issue the following query:
query MyAwesomeQuery($id: ID!) {
transaction(id: $id) {
id
amount
sender {
name
avatar_url
}
}
}
And their frontend may look a bit like this:
function TransactionDetails(props: Props) {
const transaction = props.transaction;
if (!transaction) {
return <div>Cannot find the transaction.</div>;
}
return (
<div>
<h1>{transaction.id}</h1>
<p>Amount: USD {transaction.amount}</p>
<p>The transaction was sent by {transaction.sender.name}.</p>
<img src={transaction.sender.avatar_url} />
</div>
);
}
Now, let's say that there is some issue when generating the avatar_url
of the sender
(maybe the CDN is down?). The JSON response will look like this:
{
"data": {
"transaction": null
},
"errors": [
{ "key": "AvatarException", "message": "Cannot generate the avatar URL." }
]
}
The sender
object will not be generated at all since one of its required fields is missing. This means that the transaction
itself won't generate because the sender
field is missing. And the client displays the "Cannot find the transaction." error message, which is a poor experience.
Now, let's rewrite this example with optional fields. The schema becomes:
type Query {
transaction(id: ID!): Transaction
}
type Transaction {
id: ID!
amount: Int!
sender: Person!
}
type Person {
name: String!
avatar_url: String
}
The frontend can now handle partial data:
function TransactionDetails(props: Props) {
const transaction = props.transaction;
if (!transaction) {
return <div>Cannot find the transaction.</div>;
}
return (
<div>
<h1>{transaction.id}</h1>
<p>Amount: USD {transaction.amount}</p>
<p>The transaction was sent by {transaction.sender.name}.</p>
<img src={transaction.sender.avatar_url || PLACEHOLDER_AVATAR_URL} />
</div>
);
}
If the avatar URL cannot be generated, the response will look like this:
{
"data": {
"transaction": {
"id": "1234567890",
"amount": 42,
"sender": {
"name": "Vincent",
"avatar_url": null
}
}
},
"errors": [
{ "key": "AvatarException", "message": "Cannot generate the avatar URL." }
]
}
The client can use a placeholder for the avatar URL and still display most of the information. Of course, we can take that logic further by making the sender
field optional too, etc.
As in every rule/advice, there are always exceptions. I usually make fields required when without them, the object doesn't really make "sense". This is done on a case-by-case basis but here are some examples:
amount
might not make sensename
might not make sensepublic_id_key
might not make senseUse your best judgement for the context of your application 😉