stretch.codes
ByAndrew McCallum

Unions, Errors and __typenames

Type-Safe Error Handling in GraphQL

June 15, 2025

I used to think handling errors in GraphQL was straightforward. Your query would return data and errors fields and you would use that to display the relevant information to the user. But it goes a little deeper than that. We're going to look at the different kinds of errors and how we take advantage of the __typename field.

Say you have a mutation for when a user signs up:

mutation signUp($input: SignUpInput!) {
  signUp(input: $input) {
    user {
      id
      email
      name
    }
  }
}

And from our app we might do something like this:

const [signUp, { data, error, loading }] = useMutation(signUpMutation)
 
if( error ) {
  // do something
}
 
const onSubmit = async (formData: FormData) => {
  await signUp({
    variables: {
      input: {
        email: formData.email,
        password: formData.password,
        name: formData.name,
      }
    }
  });
}

This works well - we are able to call our mutation on submit to register the new user, we then have a loading and error state that we can use to notify the user of what's happening.

Now imagine our backend throws an error to say that we already have a user registered with that email address and we want to notify the user that they actually already have an account. Our backend could throw an error and then we could pick that up on the frontend like so:

if( error ) {
  // check if it's an email error
  setError('email', error.message);
}

We then run into the issue of having to run some checks on the error to see what it's relevant to.

To make matters worse, GraphQL servers can technically return an error in any shape so we lose the nice schema validation that GraphQL would usually provide.

So how do we get around this?

Enter errors as data.

This is a way to allow us to return a union from our query or mutation whilst retaining some schema validation for the error returned.

mutation signUp($input: SignUpInput!) {
  signUp(input: $input) {
    ... on User {
      id
      email
      name
    }
    ... on ValidationError {
      field
      message
    }
  }
}

You can see here that we now return a union of User or ValidationError and we can see exactly what shape we are expecting back. We can even query the exact fields that we want.

Our app can now look something like this:

const [signUp, { data, error, loading }] = useMutation(signUpMutation)
 
if( error ) {
  // handle unexpected error
}
 
const onSubmit = async (formData: FormData) => {
  await signUp({
    variables: {
      input: {
        email: formData.email,
        password: formData.password,
        name: formData.name,
      }
    }
  });
}
 
// if ValidationError then show error messages for appropriate field
 
if (data) {
  // handle successful sign up, e.g., redirect to a welcome page
}

This raises the question: how do we distinguish a validation error from a successful response?

One common pattern I see is using type guards.

function isValidationError(response: User | ValidationError): response is ValidationError {
  return 'message' in response;
}

This might work fine initially but what if your query changes:

  1. You query another union type that also has a message field.
  2. The User type has a field named message.

Your isValidationError type guard will now return true even though there was no error.

This illustrates how brittle the type guard can be in this situation.

GraphQL provides a useful field called __typename that can help us in this situation. By querying the __typename field and using it to narrow the union type, we can infer exactly what type the object returned is. This creates what's known as a discriminated union, giving us type safety without brittle type guards.

Our query will now look something like this:

mutation signUp($input: SignUpInput!) {
  signUp(input: $input) {
    __typename
    ... on User {
      id
      email
      name
    }
    ... on ValidationError {
      field
      message
    }
  }
}

And our app can easily reference the fields it needs in a type-safe manner:

const [signUp, { data, error, loading }] = useMutation(signUpMutation)
 
if( error ) {
  // handle unexpected error
}
 
const onSubmit = async (formData: FormData) => {
  await signUp({
    variables: {
      input: {
        email: formData.email,
        password: formData.password,
        name: formData.name,
      }
    }
  });
}
 
if( data.signUp.__typename === 'ValidationError' ) {
  // TypeScript knows field and message are available 
  // because the type is inferred as typeof ValidationError
  setError(data.signUp.field, data.signUp.message); 
} else if( data.signUp.__typename === 'User' ) {
  // handle successful sign up, e.g., redirect to a welcome page
}

We are now able to maintain the benefit of our GraphQL by utilising its schema to know exactly what fields we are getting back as well as type safety from inferred types from the __typename field.

To summarise: top-level errors for unexpected errors; data as errors when the information the errors provide is relevant to the end-user experience; use __typename as a differentiator between your unions.