TypeScript GraphQL Projects: Type Generation in a Monorepo

August 29, 2023

5 min read

TypeScript GraphQL Projects: Type Generation in a Monorepo
Watch on YouTube

Let me share how I handle type generation for my TypeScript GraphQL projects in the context of a monorepo.

GraphQL Schema & Types

First, we create a new package called api-interface which will define an interface for our GraphQL API.

We add a GraphQL schema in a schema.graphql file.

To generate types from the schema, we need to install @graphql-codegen packages.

yarn add --dev @graphql-codegen/cli @graphql-codegen/client-preset @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

To run graphql-codegen, we'll add a codegen script to package.json:

{
  "name": "@increaser/api-interface",
  "version": "0.0.1",
  "private": true,
  "devDependencies": {
    "@graphql-codegen/cli": "^5.0.0",
    "@graphql-codegen/client-preset": "^4.1.0",
    "@graphql-codegen/typescript": "^4.0.1",
    "@graphql-codegen/typescript-resolvers": "^4.0.1"
  },
  "scripts": {
    "codegen": "graphql-codegen --config codegen.ts"
  }
}

As there can be only one implementation of the GraphQL API, we'll put generated types for the server in the api package under gql folder. In this example, we only have one consumer of the API under the app package, but in practice, there might be additional clients using the same API in the monorepo. We'll put generated types for the client in the app-interface package under client folder to not duplicate it between the clients.

In the codegen.ts file, we direct it to the schema file and reference the app package .ts and .tsx files that contain GraphQL queries and mutations. In the generates field, we have two destinations:

  • schema.ts file with types for the server
  • the client in the app-interface package that various clients can rely on.
import type { CodegenConfig } from "@graphql-codegen/cli"

const config: CodegenConfig = {
  overwrite: true,
  schema: "./schema.graphql",
  documents: "../app/**/!(*.d).{ts,tsx}",
  generates: {
    "../api/gql/schema.ts": {
      plugins: [
        {
          typescript: {
            enumsAsConst: true,
          },
        },
        "typescript-resolvers",
      ],
    },
    "./client/": {
      preset: "client",
      config: {
        enumsAsTypes: true,
      },
    },
  },
}

export default config

Generated GraphQL Types & Apollo Server

I use Apollo Server to implement the interface, and esbuild to build the project for AWS Lambda. We import the schema.graphql file from the app-interface package as follows:

import schemaPath from "@increaser/api-interface/schema.graphql"
import path from "path"

import { readFileSync } from "fs"

export const typeDefs = readFileSync(path.join(__dirname, schemaPath), {
  encoding: "utf-8",
})

Esbuild then copies the file to the root of the dist folder under a different name, and we can read it from there. To achieve this behavior, we need to add a loader to the esbuild config:

import * as esbuild from "esbuild"

await esbuild.build({
  entryPoints: ["./lambda.ts"],
  bundle: true,
  minify: true,
  sourcemap: true,
  platform: "node",
  target: "es2020",
  outfile: "./dist/lambda.js",
  loader: {
    ".graphql": "file",
  },
})

Then, in the resolvers.ts file, we can import the Resolvers type and implement Mutation and Query resolvers:

import { Resolvers } from "./schema"

export const resolvers: Pick<Resolvers, "Query" | "Mutation"> = {
  Query: {
    //...
  },
  Mutation: {
    //...
  },
}

Since I store each resolver in a separate file, I'll use the MutationResolvers or QueryResolvers to type them:

export const createHabit: MutationResolvers["createHabit"] = async (
  _,
  { input },
  context: OperationContext
) => {
  //...
}

Generated GraphQL Types & React Query

On the front-end, I rely on the useApi hook to query the API, and the react-query library to cache the data.

import { graphql } from "@increaser/api-interface/client"

const redeemAppSumoCodeMutationDocument = graphql(`
  mutation redeemAppSumoCode($input: RedeemAppSumoCodeInput!) {
    redeemAppSumoCode(input: $input)
  }
`)

By using the graphql function, we get a typed document node that we can pass to the useApi's query hook:

import { useAuth } from "auth/hooks/useAuth"
import { ApiErrorCode } from "@increaser/api/errors/ApiErrorCode"
import { shouldBeDefined } from "@increaser/utils/shouldBeDefined"
import { TypedDocumentNode } from "@graphql-typed-document-node/core"
import { print } from "graphql"

interface ApiErrorInfo {
  message: string
  extensions: {
    code: string
  }
}

interface ApiResponse<T> {
  data: T
  errors: ApiErrorInfo[]
}

export class ApiError extends Error {}

class HttpError extends Error {
  public status: number

  constructor(status: number, message: string) {
    super(message)
    this.status = status
  }
}

export type Variables = Record<string, unknown>

export type QueryApi = <T, V extends Variables = Variables>(
  document: TypedDocumentNode<T, V>,
  variables?: V
) => Promise<T>

export const useApi = () => {
  const { unauthorize, token } = useAuth()

  const headers: HeadersInit = {
    "Content-Type": "application/json",
  }
  if (token) {
    headers.Authorization = token
  }

  const query: QueryApi = async <T, V>(
    document: TypedDocumentNode<T, V>,
    variables?: V
  ) => {
    const apiUrl = shouldBeDefined(process.env.NEXT_PUBLIC_API_URL)

    const response = await window.fetch(apiUrl, {
      method: "POST",
      headers,
      body: JSON.stringify({
        query: print(document),
        variables,
      }),
    })

    if (!response.ok) {
      throw new HttpError(response.status, response.statusText)
    }

    const { data, errors } = (await response.json()) as ApiResponse<T>

    if (errors?.length) {
      const { message, extensions } = errors[0]
      if (extensions?.code === ApiErrorCode.Unauthenticated) {
        unauthorize()
      }

      throw new ApiError(message)
    }

    return data
  }

  return { query }
}

Here, we use a JWT token for authorization that we add to the headers under the Authorization key. The query function receives a typed-document from the @graphql-typed-document-node/core library. We use window.fetch to make the request. We take apiUrl from environment variables and stringify the query and variables. The print function from graphql library is used to convert the document to a plain string.

Our GraphQL should always return 200 so if there's a problem with the request, it's likely not related to the API and we throw an HttpError. If errors exist in the response, we throw an ApiError that can be handled in the UI. If the error indicates that it's Unauthenticated, we call the unauthorize function which will redirect the user to the login page.