You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
After reading a lot about the current nullability discussions, I had some kind of realization that at it's core, the problem can be seen as an "exception handling" problem and the good old checked vs unchecked exceptions debate. Let me elaborate a bit.
If we're thinking in Rust Option and Error terms, the current situation is this:
Types that can be represented by the GraphQL type system:
// these types can be represented in GraphQL// non-nullable, non-error String (`String!` in GraphQL)String// nullable String or error (String in GraphQL)Result<Option<String>,Error>
Types that currently cannot be represented by the GraphQL type system:
// these types cannot be represented in GraphQL// non-nullable String or Error (?? in GraphQL)Result<String,Error>
// nullable String (?? in GraphQL)Option<String>
If I got Strict Semantic Nullability and SemanticNonNull proposals right, they would solve the Result<String, Error> by allowing non-null fields to return an error (without bubbling). This is a huge step forward but still feels a bit incomplete in terms of type system as it cannot represent the 4 possible states. Ideally, I'd like the type system to be able to represent everything:
# this is beautiful 🤩
StringOption<String>
Result<String,Error>
Result<Option<String>,Error>
Thinking in terms of exceptions
I find it's a lot easier to reason about GraphQL types if we think of errors as exceptions.
The current situation of nullable by default schemas is closer to checked exceptions: the caller has to check for null (== error) when writing UI logic. This is cumbersome, especially when reading the exception requires reading the errors array.
Unchecked GraphQL exceptions
We could think of GraphQL errors as unchecked exceptions. In a nutshell, any field may throw.
Under this premise, the GraphQL type system becomes as easy as a single nullable type modifier:
# endgoal type system# errors are implicit, any field may throw, no matter its nullability statustypeFoo {
# non-nullable field, may throwfield1: String # nullable field, may also throwfield2: String?
}
For the caller, the contract is a lot simpler to reason about:
The declared type is the semantic type
Any field may throw
By default a field exception fails the whole query making error handling easy
Advanced users may opt-in more fine grained error handling and partial data using directives such as @catch
For the tools authors, it means more complexity:
read errors at runtime
add @catch support
The tradeoff is about more clarity vs more complex tools. I would argue that there are a lot more users than there are tools authors and that if we could make the tooling easy enough to use, the tooling complexity would be amortized quickly. But maybe that's too much complexity? There's a tension here.
IO exceptions vs business errors
Another benefit of thinking in terms of exceptions is that the checked vs unchecked question has been debated a lot, especially in the Java ecosystem, which I happen to know the best.
Except for I/O errors that can happen in a lot of places and where that becomes cumbersome. In that case, using exceptions is fine.
I like this distinction. Especially, I like to think that the errors described in the GraphQL best practices belong to that second class of I/O errors. Any error that has a business impact (BadEmail, UserDoesNotExists, etc...) is probably better modeled in the schema.
That I/O exception vs business distinction makes it clear when to rely on what.
Questions
Question: what about Rust?
Interestingly, Rust doesn't have exceptions. I'd be curious to know how I/O errors are handled. If you need to parse a JSON where every byte read can fail, how is this handled?
Question: isn't that more complex?
Unchecked exceptions are definitely more complex because we need clients that are error-aware. Software has become more complex over the last decades.
This is good because it gives nicer abstractions to work with (who wants to write assembly code? 😄)
This is also bad because every abstraction has a cost (who wants to buy more RAM to send a slack message?)
Question: what about backward compatibility?
I have absolutely no idea how we reach the endgoal type system without breaking every server and client out there. Problem for another day 😃
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
-
After reading a lot about the current nullability discussions, I had some kind of realization that at it's core, the problem can be seen as an "exception handling" problem and the good old checked vs unchecked exceptions debate. Let me elaborate a bit.
If we're thinking in Rust Option and Error terms, the current situation is this:
Types that can be represented by the GraphQL type system:
Types that currently cannot be represented by the GraphQL type system:
If I got Strict Semantic Nullability and SemanticNonNull proposals right, they would solve the
Result<String, Error>
by allowing non-null fields to return an error (without bubbling). This is a huge step forward but still feels a bit incomplete in terms of type system as it cannot represent the 4 possible states. Ideally, I'd like the type system to be able to represent everything:Thinking in terms of exceptions
I find it's a lot easier to reason about GraphQL types if we think of errors as exceptions.
The current situation of nullable by default schemas is closer to checked exceptions: the caller has to check for null (== error) when writing UI logic. This is cumbersome, especially when reading the exception requires reading the errors array.
Unchecked GraphQL exceptions
We could think of GraphQL errors as unchecked exceptions. In a nutshell, any field may throw.
Under this premise, the GraphQL type system becomes as easy as a single nullable type modifier:
For the caller, the contract is a lot simpler to reason about:
@catch
For the tools authors, it means more complexity:
@catch
supportThe tradeoff is about more clarity vs more complex tools. I would argue that there are a lot more users than there are tools authors and that if we could make the tooling easy enough to use, the tooling complexity would be amortized quickly. But maybe that's too much complexity? There's a tension here.
IO exceptions vs business errors
Another benefit of thinking in terms of exceptions is that the checked vs unchecked question has been debated a lot, especially in the Java ecosystem, which I happen to know the best.
My current take is very much inspired by this article from Roman Elizarov: https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07
The tldr; is:
I like this distinction. Especially, I like to think that the errors described in the GraphQL best practices belong to that second class of I/O errors. Any error that has a business impact (
BadEmail
,UserDoesNotExists
, etc...) is probably better modeled in the schema.That I/O exception vs business distinction makes it clear when to rely on what.
Questions
Question: what about Rust?
Interestingly, Rust doesn't have exceptions. I'd be curious to know how I/O errors are handled. If you need to parse a JSON where every byte read can fail, how is this handled?
Question: isn't that more complex?
Unchecked exceptions are definitely more complex because we need clients that are error-aware. Software has become more complex over the last decades.
This is good because it gives nicer abstractions to work with (who wants to write assembly code? 😄)
This is also bad because every abstraction has a cost (who wants to buy more RAM to send a slack message?)
Question: what about backward compatibility?
I have absolutely no idea how we reach the endgoal type system without breaking every server and client out there. Problem for another day 😃
Beta Was this translation helpful? Give feedback.
All reactions