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

Recently mixing TypeScript and GraphQL is becoming a web development defacto standard. However, there is not so much information of how to work on 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.

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>
  ))
}
It looks nice, but imagine the type of data . Yes, it is any (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>
  ))
}
As you can see, this is so painful, because every time we update our query, we should manually update its type 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.)
Let's execute some commands to create type definitions.
npx apollo client:codegen \
  --localSchemaFile schema.gql \
  --target typescript \
  --includes 'src/**/*.{ts,tsx}'
(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[]
}
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>
  ))
}
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 seperate 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} />
  ))
}
// 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>
  )
}
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 using domain seperation and Fragment should be the solution for it.
    // graphql/Rate.tsx
    
    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
    
    // ExchangeRates.tsx
    
    import { useRates } from "./graphql/Rate
    
    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} />
      ))
    }
    
    // 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>
      )
    }
    
    Since we move GraphQL code to ./graphql/Rate , the dependency became linear again;
  • ExchangeRates -> graphql/Rate -> __generated__
  • ExchangeRates -> ExchangeRateItem -> graphql/Rate -> __generated__
  • And the code on component become smaller, which is also great for frontend coders.

    Conclusion

  • Use apollo 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!

    좋은 웹페이지 즐겨찾기