DEV Community

Kay Gosho
Kay Gosho

Posted on • Edited on

Step by step guide of how to painlessly type GraphQL execution result

Recently mixing TypeScript and GraphQL is becoming a defacto standard of modern web development. However, there is not so much information of how to combine them without hassle.

In conclusion, I've found Fragment first approach should painlessly work with TypeScript. Why? it accelerates type definition's reusability. Let's see how it works.

[edited]
I realized that Colocating Fragment is the best for GraphQL + TypeScript while trying to implement some real-world frontend projects (as freelance jobs). That pattern is a little verbose, but declarative and easily scalable. So instead of reading this article, you should read carefully: https://www.apollographql.com/docs/react/data/fragments/#colocating-fragments
[/edited]

Step 1 - No type

In the example from react-apollo, you can see the following code.

import { useQuery, gql } from "@apollo/client"

const EXCHANGE_RATES = gql`
  query GetExchangeRates {
    rates(currency: "USD") {
      currency
      rate
    }
  }
`

function ExchangeRates() {
  const { loading, error, data } = useQuery(EXCHANGE_RATES)

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error :(</p>

  return data.rates.map(({ currency, rate }) => (
    <div key={currency}>
      <p>
        {currency}: {rate}
      </p>
    </div>
  ))
}
Enter fullscreen mode Exit fullscreen mode

It looks nice, but imagine the type of data. Yes, it is any. It breaks type-safety and you will go mad!

Note: technically not any but Record, although they are almost same in this context

Step 2 - Type manually

To avoid data become any, we can type the query result using TypeScript's generics feature.

import { useQuery, gql } from "@apollo/client"

interface GetExchangeRates {
  rates: {
    currency: string
    rate: number
  }[]
}

const EXCHANGE_RATES = gql`
  query GetExchangeRates {
    rates(currency: "USD") {
      currency
      rate
    }
  }
`

function ExchangeRates() {
  const { loading, error, data } = useQuery<GetExchangeRates>(EXCHANGE_RATES)

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error :(</p>

  // Type signature of `data` is:
  // {
  //   rates: {
  //     currency: string
  //     rate: number
  //   }[]
  // }

  return data.rates.map(({ currency, rate }) => (
    <div key={currency}>
      <p>
        {currency}: {rate}
      </p>
    </div>
  ))
}
Enter fullscreen mode Exit fullscreen mode

As you can see, this is so painful! Every time you update your query, you should manually update its interface too.

Step 3 - Type codegen

Fortunately, we can generate TypeScript's type definitions from GraphQL queries using apollo-tooling.

https://github.com/apollographql/apollo-tooling#apollo-clientcodegen-output

Note: there are some tools other than apollo, but I prefer apollo because it is the most minimal one.

Let's execute some commands to create type definitions.

npx apollo client:codegen \
  --localSchemaFile schema.gql \
  --target typescript \
  --includes 'src/**/*.{ts,tsx}'
Enter fullscreen mode Exit fullscreen mode

Note: If you run the above command it will fail, because you won't have schema.gql in local.

Ensure you have schema.gql. Your GraphQL server should have the feature to emit your GraphQL schema to a file.

After the command, you will see a output file including code like this:

// __generated__/GetExchangeRates.ts

export interface GetExchangeRates_rate {
  currency: string
  rate: number
}

export interface GetExchangeRates {
  rates: GetExchangeRates_rate[]
}
Enter fullscreen mode Exit fullscreen mode

So we can replace the last code with the generated types:

import { useQuery, gql } from "@apollo/client"
import { GetExchangeRates } from "./__generated__/GetExchangeRates"

const EXCHANGE_RATES = gql`
  query GetExchangeRates {
    rates(currency: "USD") {
      currency
      rate
    }
  }
`

function ExchangeRates() {
  const { loading, error, data } = useQuery<GetExchangeRates>(EXCHANGE_RATES)

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error :(</p>

  return data.rates.map(({ currency, rate }) => (
    <div key={currency}>
      <p>
        {currency}: {rate}
      </p>
    </div>
  ))
}
Enter fullscreen mode Exit fullscreen mode

This is much easier!

The downside is that we should run the command to generate type definitions every time we edit GraphQL code, but it is far easier than manual typing.

I think it is enough for smaller projects. But if the project grows, there will be a problem - type reusability.

Step 4 - Reuse type definitions

Thanks to apollo, we can generate type definitions. However, how to reuse these type definitions?

Imagine we want to separate our component like this:

// ExchangeRates.tsx

import { useQuery, gql } from "@apollo/client"
import { GetExchangeRates } from "./__generated__/GetExchangeRates"
import { ExchangeRateItem } from "./ExchangeRateItem"

const EXCHANGE_RATES = gql`
  query GetExchangeRates {
    rates(currency: "USD") {
      currency
      rate
    }
  }
`

function ExchangeRates() {
  const { loading, error, data } = useQuery<GetExchangeRates>(EXCHANGE_RATES)

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error :(</p>

  return data.rates.map((rate) => (
    <ExchangeRateItem rate={rate} key={rate.currency} />
  ))
}
Enter fullscreen mode Exit fullscreen mode
// ExchangeRateItem.tsx

import { GetExchangeRates_rate } from "./__generated__/GetExchangeRates"

interface ExchangeRateItemProps {
  rate: GetExchangeRates_rate
}

export function ExchangeRateItem({ rate }: ExchangeRateItemProps) {
  const { currency, rate } = rate
  return (
    <div>
      <p>
        {currency}: {rate}
      </p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we can import common GraphQL type definitions from generated code. However, it should become messy because:

  • The child component relies on parent's component query.
  • Hard to re-use ExchangeRateItem because of tied couple of a specific query.
  • Dependency flow is not linear; ExchangeRateItem -> __generated__ -> ExchangeRates -> ExchangeRateItem

Note: Technically __generated__ does not depends on ExchangeRates, but conceptually it depends, as type definitions are generated from it.

I haven't fully figured out how to handle this, but have two solutions for it using Fragment.

Step 4.1 - Create common Query and Fragment

The first one is based on Domain Separation. The idea is to create common GraphQL related files and write logic there instead of components:

// graphql/Rate.ts

import { useQuery, gql } from "@apollo/client"
import {
  GetExchangeRates,
  GetExchangeRates_rate,
} from "./__generated__/GetExchangeRates"

// Re-export fragment type because of reusability
export type { RateFragment } from "./ExchangeRateItem"

const RATE_FRAGMENT = gql`
  fragment RateFragment on Rate {
    currency
    rate
    # ...And other props in the future
  }
`

const EXCHANGE_RATES = gql`
  query GetExchangeRates {
    rates(currency: "USD") {
      ...RateFragment
    }
  }
  ${RATE_FRAGMENT}
`

export const useRates = () => useQuery<GetExchangeRates>(EXCHANGE_RATES)

// Other fragments, hooks, queries will follow
Enter fullscreen mode Exit fullscreen mode
// ExchangeRates.tsx

import { useRates } from "./graphql/Rate"
import { ExchangeRateItem } from "./ExchangeRateItem"

function ExchangeRates() {
  const { loading, error, data } = useRates()

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error :(</p>

  return data.rates.map((rate) => (
    <ExchangeRateItem rate={rate} key={rate.currency} />
  ))
}
Enter fullscreen mode Exit fullscreen mode
// ExchangeRateItem.tsx

import { RateFragment } from "./graphql/Rate"

interface ExchangeRateItemProps {
  rate: RateFragment
}

export function ExchangeRateItem({ rate }: ExchangeRateItemProps) {
  const { currency, rate } = rate
  return (
    <div>
      <p>
        {currency}: {rate}
      </p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Since we move GraphQL code to ./graphql/Rate, the dependency became linear again;

  • ExchangeRates -> graphql/Rate -> __generated__
  • ExchangeRates -> ExchangeRateItem -> graphql/Rate -> __generated__

Using Fragment, code for GraphQL became a little longer and verbose. However, it has a benefit of Separation of Concerns.

  • graphql/Rate knows how to fetch data.
  • graphql/Rate exposes its interface.
  • ExchangeRates and ExchangeRateItem don't know how to fetch data. They don't depend on implementation but interface of data source and type.

And the code on our components become smaller, which is also great for frontend devs.

Step 4.2 - Colocated Fragments

Another solution is to use a pattern called "Colocated Fragments" where child components declare which data is needed.

// ExchangeRates.tsx

import { useQuery, gql } from "@apollo/client"
import { ExchangeRateItem, RATE_ITEM_FRAGMENT } from "./ExchangeRateItem"

const EXCHANGE_RATES = gql`
  query GetExchangeRates {
    rates(currency: "USD") {
      ...RateItemFragment
    }
  }
  ${RATE_ITEM_FRAGMENT}
`

function ExchangeRates() {
  const { loading, error, data } = useQuery<GetExchangeRates>(EXCHANGE_RATES)

  if (loading) return <p>Loading...</p>
  if (error) return <p>Error :(</p>

  return data.rates.map((rate) => (
    <ExchangeRateItem rate={rate} key={rate.currency} />
  ))
}
Enter fullscreen mode Exit fullscreen mode
// ExchangeRateItem.tsx

import { gql } from "@apollo/client"
import { RateItemFragment } from "./__generated__/RateItemFragment"

export const RATE_ITEM_FRAGMENT = gql`
  fragment RateItemFragment on Rate {
    currency
    rate
    # ...And other props in the future
  }
`

interface ExchangeRateItemProps {
  rate: RateItemFragment
}

export function ExchangeRateItem({ rate }: ExchangeRateItemProps) {
  const { currency, rate } = rate
  return (
    <div>
      <p>
        {currency}: {rate}
      </p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

In that way, we achieve:

  • We don't have to separate GraphQL code form components which needs it
  • We don't have to update fields manually when required data changes
  • Easy to read code

For more details, please visit: https://www.apollographql.com/docs/react/data/fragments/#colocating-fragments

Codegen tools other than Apollo

TypedDocumentNode

@urigo recommended TypedDocumentNode. I have never tried the library, but he is one of the smartest GraphQL developer so you should check it out!

https://the-guild.dev/blog/typed-document-node

@graphql-codegen/cli

This is made by the CTO of The Guild, and widely used. I haven't tried it in my project yet, but it covers almost all of major tools.

https://github.com/dotansimha/graphql-code-generator

Conclusion

  • Use apollo-tooling or other tools to type GraphQL result
  • Separate GraphQL related code into a directory (if you think your project is large)
  • Use Fragment to create common reusable type

If you have any thoughts, please post a comment!

Top comments (4)

Collapse
 
urigo profile image
Uri Goldshtein • Edited

Great article, thank you so much!

I would also add a last and final step to make it even cleaner - use generated TypedDocumentNode!
Here is now: the-guild.dev/blog/typed-document-...

Collapse
 
acro5piano profile image
Kay Gosho

Thank you for your comment, Urigo!

I saw the blog and TypedDocumentNode is impressive. Thanks!!

Collapse
 
angelo1104 profile image
Angelo

I guess you left off the server side stuff. I personally prefer to work with nest js and graphql as nest js automatically generates all the types for me.

Collapse
 
acro5piano profile image
Kay Gosho

Thanks for your comment. As you mentioned this article is totally about frontend. Nestjs looks great whereas I use type-graphql for my life now.