DEV Community

Cover image for GraphQL Typeguards
Nicolas Toulemont
Nicolas Toulemont

Posted on • Updated on • Originally published at nicolastoulemont.dev

GraphQL Typeguards

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Whose GraphQL return type is :

union UserResult =
      ActiveUser
    | BannedUser
    | DeletedUser
    | InvalidArgumentsError
    | UserAuthenticationError
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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]
            }
        })
    }
})
Enter fullscreen mode Exit fullscreen mode

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]
            }
        })
    }
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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] }>
Enter fullscreen mode Exit fullscreen mode

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]
            }
        })
    }
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]
            }
        })
    }
})
Enter fullscreen mode Exit fullscreen mode

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]
)
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }>
Enter fullscreen mode Exit fullscreen mode

This tell typescript what to expect from it. Now we can use it as follow:

const activeUsers = useMemo(() => data?.users?.filter(isTypeInTuple('ActiveUser')) ?? [], [data])
Enter fullscreen mode Exit fullscreen mode

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.

Oldest comments (0)