August 29, 2023
5 min read
Let me share how I handle type generation for my TypeScript GraphQL projects in the context of a monorepo.
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 serverclient
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
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
) => {
//...
}
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.