TypeScript GraphQL Projects: Type Generation in a Monorepo
Tags: typescript, graphql, monorepo, typegeneration
Published: 2023-08-30 04:58:05

✨ [**Watch on YouTube**](https://youtu.be/EW6QFjJWGnQ)

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.

```sh
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`:

```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.

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```tsx
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:

```tsx
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.