GraphQL Typeguards
Nicolas Toulemont
Posted on May 12, 2021
When working with GraphQL, one will sometimes need to assert the type of the response. Sometimes it is because of the response is a union type, sometimes because the response is a nullable result. This usually forces the developer into asserting the response type quite often which can cause a bit of noise.
To handle these assertions we will have a look at a few helpful typeguards functions: isType, isEither, isNot, isTypeInTuple.
Simple use case
For example, when asserting the result of the following mutation response, the developer will need to handle three different cases: an ActiveUser, a UserAuthenticationError and a InvalidArgumentsError.
mutation CreateUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) {
... on ActiveUser {
id
name
status
email
}
... on UserAuthenticationError {
code
message
}
... on InvalidArgumentsError {
code
message
invalidArguments {
key
message
}
}
}
}
It could look like something like this:
const initialUserState = {
name: '',
email: ''
}
function UserForm() {
const [{ name, email }, setState] = useState(initialUserState)
const [errors, setErrors] = useState({})
const [saveUser] = useCreateUserMutation({
variables: {
name,
email
}
})
async function handleSubmit(event) {
event.preventDefault()
const { data } = await saveUser()
switch (data.createUser.__typename) {
case 'ActiveUser':
setState(initialUserState)
setErrors({})
case 'UserAuthenticationError':
// Display missing authentication alert / toast
case 'InvalidArgumentsError':
setErrors(toErrorRecord(data.createUser.invalidArguments))
default:
break
}
}
return (
//... Form JSX
)
}
And for that simple use case, it would be fine. But what if we also want to update our client side apollo client cache to include the newly created user into it ?
Then our handleSubmit function would look this:
async function handleSubmit(event) {
event.preventDefault()
const { data } = await saveUser({
update: (cache, { data: { createUser } }) => {
const existingUsers = cache.readQuery<UsersQuery>({ query: GET_USERS })
if (data.createUser.__typename === 'ActiveUser') {
cache.writeQuery({
query: GET_USERS,
data: {
users: [...existingUsers.users, createUser]
}
})
}
}
})
switch (data.createUser.__typename) {
case 'ActiveUser':
setState(initialUserState)
setErrors({})
case 'UserAuthenticationError':
// Display missing authentication alert / toast
case 'InvalidArgumentsError':
setErrors(toErrorRecord(data.createUser.invalidArguments))
default:
break
}
}
And that would fine too, but we are starting to have multiple .__typename assertion. And this can get out of hand quite quickly. That is when a utility type-guard function can come in.
Let's make a simple isType typeguard base on the __typename property:
isType
type GraphQLResult = { __typename: string }
type ValueOfTypename<T extends GraphQLResult> = T['__typename']
function isType<Result extends GraphQLResult, Typename extends ValueOfTypename<Result>>(
result: Result,
typename: Typename
): result is Extract<Result, { __typename: Typename }> {
return result?.__typename === typename
}
With this typeguard we use the Typescript Extract utility type with the is
expression to tell the Typescript compiler which type our result is.
And now our submit function would look this :
async function handleSubmit(event) {
event.preventDefault()
const { data } = await saveUser({
update: (cache, { data: { createUser } }) => {
const existingUsers = cache.readQuery<UsersQuery>({ query: GET_USERS })
if (isType(createUser, 'ActiveUser')) {
cache.writeQuery({
query: GET_USERS,
data: {
users: [...existingUsers.users, createUser]
}
})
}
}
})
if (isType(data?.createUser, 'ActiveUser')) {
setState(initialUserState)
setErrors({})
} else if (isType(data?.createUser, 'UserAuthenticationError')) {
// Display missing authentication alert / toast
} else if (isType(data?.createUser, 'InvalidArgumentsError')) {
setErrors(toErrorRecord(data.createUser.invalidArguments))
}
}
That a bit better, we get some type safety, the typename parameter of the isType has some nice autocomplete and the logic is easily readable and explicite.
Admittedly this isn't a major improvement, but the isType function can be composed is many different ways to handle more complexe cases.
More complexe use cases
Now, let's say that our GET_USERS query is the following:
query Users {
users {
... on ActiveUser {
id
name
status
email
posts {
id
title
}
}
... on DeletedUser {
id
name
status
deletedAt
}
... on BannedUser {
id
name
status
banReason
}
}
}
Whose GraphQL return type is :
union UserResult =
ActiveUser
| BannedUser
| DeletedUser
| InvalidArgumentsError
| UserAuthenticationError
And that we want to be able to change the status of the users and then update our cache accordingly so that it reflect the updated status of the user.
We would have a mutation like this:
mutation ChangeUserStatus($status: UserStatus!, $id: Int!) {
changeUserStatus(status: $status, id: $id) {
... on ActiveUser {
id
name
status
email
posts {
id
title
}
}
... on DeletedUser {
id
name
status
deletedAt
}
... on BannedUser {
id
name
status
banReason
}
... on UserAuthenticationError {
code
message
}
... on InvalidArgumentsError {
code
message
invalidArguments {
key
message
}
}
}
}
Now to implement this mutation and update the cache based on the response type we will have something like this:
const [changeUserStatus] = useChangeUserStatusMutation({
update: (cache, { data: { changeUserStatus } }) => {
const existingUsers = cache.readQuery<UsersQuery>({ query: GET_USERS })
const filteredUsers = existingUsers.users.filter(
(user) =>
(user.__typename === 'ActiveUser' ||
user.__typename === 'DeletedUser' ||
user.__typename === 'BannedUser') &&
(changeUserStatus.__typename === 'ActiveUser' ||
changeUserStatus.__typename === 'DeletedUser' ||
changeUserStatus.__typename === 'BannedUser') &&
user.id !== changeUserStatus.id
)
cache.writeQuery({
query: GET_USERS,
data: {
users: [...filteredUsers, changeUserStatus]
}
})
}
})
Now that is quite a bit verbose. We could instead use our isType function reduce the noise a bit:
const [changeUserStatus] = useChangeUserStatusMutation({
update: (cache, { data: { changeUserStatus } }) => {
const existingUsers = cache.readQuery<UsersQuery>({ query: GET_USERS })
const filteredUsers = existingUsers.users.filter(
(user) =>
(isType(user, 'ActiveUser') ||
isType(user, 'DeletedUser') ||
isType(user, 'BannedUser')) &&
(isType(changeUserStatus, 'ActiveUser') ||
isType(changeUserStatus, 'DeletedUser') ||
isType(changeUserStatus, 'BannedUser')) &&
user.id !== changeUserStatus.id
)
cache.writeQuery({
query: GET_USERS,
data: {
users: [...filteredUsers, changeUserStatus]
}
})
}
})
But that is still not that good. Maybe we should try build a typeguard that help us figure out if the user and the mutation result are either an ActiveUser, a DeletedUser or a BannedUser.
Or maybe we should have a function to exclude types to assert that the user and the mutation result are not an UserAuthenticationError or a InvalidArgumentsError.
Let's start with the isEither function.
isEither
type GraphQLResult = { __typename: string }
type ValueOfTypename<T extends GraphQLResult> = T['__typename']
function isEither<
Result extends GraphQLResult,
Typename extends ValueOfTypename<Result>,
PossibleTypes extends Array<Typename>
>(
result: Result,
typenames: PossibleTypes
): result is Extract<Result, { __typename: typeof typenames[number] }> {
const types = typenames?.filter((type) => isType(result, type))
return types ? types.length > 0 : false
}
This isEither function simply composes the isType function while iterating on the given typenames.
The type assertion is based on:
result is Extract<Result, { __typename: typeof typenames[number] }>
Which assert that the result is one of a union of the indexed values of the typenames array.
And now our changeUserStatus mutation and cache update can be refactor like this:
const [changeUserStatus] = useChangeUserStatusMutation({
update: (cache, { data: { changeUserStatus } }) => {
const existingUsers = cache.readQuery<UsersQuery>({ query: GET_USERS })
const filteredUsers = existingUsers.users.filter(
(user) =>
isEither(user, ['ActiveUser', 'BannedUser', 'DeletedUser']) &&
isEither(changeUserStatus, ['ActiveUser', 'BannedUser', 'DeletedUser']) &&
user.id !== changeUserStatus.id
)
cache.writeQuery({
query: GET_USERS,
data: {
users: [...filteredUsers, changeUserStatus]
}
})
}
})
A bit better ! Now let's have a go at the isNot function.
isNot
type GraphQLResult = { __typename: string }
type ValueOfTypename<T extends GraphQLResult> = T['__typename']
function isNot<
Result extends GraphQLResult,
Typename extends ValueOfTypename<Result>,
ExcludedTypes extends Array<Typename>
>(
result: Result,
typenames: ExcludedTypes
): result is Exclude<Result, { __typename: typeof typenames[number] }> {
const types = typenames?.filter((type) => isType(result, type))
return types ? types.length === 0 : false
}
As you can see, the isNot function is pretty much the mirror of the isEither function.
Instead of the Extract utility type, we use the Exclude one and the runtime validation is the opposite, checking for a types length of 0.
const [changeUserStatus] = useChangeUserStatusMutation({
update: (cache, { data: { changeUserStatus } }) => {
const existingUsers = cache.readQuery<UsersQuery>({ query: GET_USERS })
const filteredUsers = existingUsers.users.filter(
(user) =>
isNot(user, ['UserAuthenticationError', 'InvalidArgumentsError']) &&
isNot(changeUserStatus, ['UserAuthenticationError', 'InvalidArgumentsError']) &&
user.id !== changeUserStatus.id
)
cache.writeQuery({
query: GET_USERS,
data: {
users: [...filteredUsers, changeUserStatus]
}
})
}
})
Finally let's have a go at the isTypeInTuple function that will help us with filtering types from tuples.
isTypeInTuple
Now let's imaging we have our same query but we want to render our ActiveUsers, DeletedUsers and BannedUsers in diffent lists.
In order to do that we will need to filter our users into three different arrays.
const { data, loading } = useUsersQuery()
const activeUsers = useMemo(
() => data?.users?.filter((user) => isType(user, 'ActiveUser')) ?? [],
[data]
)
One could think that the previous filtering is enough to get the correct users and at runtime and it is. But sadly Typescript doesn't understand that now activeUsers is an array ActiveUsers only. So we will get annoying and unwarranted type errors when consuming the activeUsers array.
In order to handle that, we could need to cast the activeUsers array as Array<ActiveUser>
but if we can avoid type casting, why not do it ? That's when the isTypeInTuple come in.
type GraphQLResult = { __typename: string }
type ValueOfTypename<T extends GraphQLResult> = T['__typename']
export function isTypeInTuple<
ResultItem extends GraphQLResult,
Typename extends ValueOfTypename<ResultItem>
>(
typename: Typename
): (resultItem: ResultItem) => resultItem is Extract<ResultItem, { __typename: Typename }> {
return function (
resultItem: ResultItem
): resultItem is Extract<ResultItem, { __typename: Typename }> {
return isType(resultItem, typename)
}
}
This function by returning a callback allow us to tell typescript that the call return is the given type.
The way the type is asserted is similar to our other functions. But instead of only asserting our typeguard return type we assert the type of the callback itself:
(resultItem: ResultItem) => resultItem is Extract<ResultItem, { __typename: Typename }>
This tell typescript what to expect from it. Now we can use it as follow:
const activeUsers = useMemo(() => data?.users?.filter(isTypeInTuple('ActiveUser')) ?? [], [data])
And we will get a correctly typed ActiveUser array.
If you found this helpful and want to use these functions, I have packaged them in a npm package called gql-typeguards.
Posted on May 12, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.