Guide
GraphQL
This page covers createGraphQL, the client for a GraphQL backend. Read it when your API speaks GraphQL. Most of the guide applies unchanged, and the last section lists what differs.
createGraphQL calls return the same Result, run the same middleware and report errors the same way as REST calls. Every operation is sent as a POST with { query, variables }:
import { createGraphQL, Operation, gql } from 'liaise'
type Category = { id: string; name: string; status: string }
const GET_CATEGORY = gql`
query GetCategory($id: String!) {
category(id: $id) {
id
name
status
}
}
`
// The second type is the response's data, keyed by the field the query selects.
const getCategory = new Operation<{ id: string }, { category: Category }>({
operation: GET_CATEGORY,
})
const graphql = createGraphQL({
endpoint: 'https://api.example.com/graphql',
operations: { getCategory },
onError: (error) => console.error(error.status, error.body),
})
const { data, error, response, retry } = await graphql.getCategory({ id: '123' })
The call sends a POST to the endpoint with a JSON body: the text of GET_CATEGORY as query, and { "id": "123" } as variables.
Operation<TVariables, TData> takes the variables type first and the type of the response’s data second. data is keyed by the fields the query selects, so here it is { category: Category }, and the name is data.category.name. gql marks the string as GraphQL for your editor.
liaise doesn’t read the document, so these are types you write, and nothing checks them against your schema. A code generator can write them instead: GraphQL Code Generator’s typescript-operations plugin produces a data type and a variables type for each named operation, such as GetCategoryQuery and GetCategoryQueryVariables, and those go straight into Operation<…>. liaise takes the document as a string, so a TypedDocumentNode doesn’t plug in.
An operation with no variables is called without arguments. Write Record<string, never> as its variables type:
type Viewer = { id: string; name: string }
const getViewer = new Operation<Record<string, never>, { viewer: Viewer }>({
operation: gql`query { viewer { id name } }`,
})
const api = createGraphQL({ endpoint: 'https://api.example.com/graphql', operations: { getViewer } })
const { data } = await api.getViewer() // no arguments
Queries and mutations
To keep queries and mutations apart, use the queries and mutations keys instead of operations. Each client uses one shape or the other, and TypeScript refuses both together.
const graphql = createGraphQL({
endpoint: 'https://api.example.com/graphql',
queries: {
getCategory: new Operation<{ id: string }, { category: Category }>({ operation: GET_CATEGORY }),
},
mutations: {
updateCategory: new Operation<{ id: string; name: string }, { updateCategory: Category }>({
operation: gql`
mutation UpdateCategory($id: String!, $name: String!) {
updateCategory(id: $id, name: $name) { id name status }
}
`,
}),
},
})
graphql.query.getCategory({ id: '123' })
graphql.mutation.updateCategory({ id: '123', name: 'New Name' })
A query and a mutation may have the same name. Each runs its own operation, and dedupe and share treat them as separate endpoints, so a query never cancels or joins the mutation of the same name.
Caching queries
GraphQL sends every operation as a POST, and cacheMiddleware caches only GET and HEAD unless you tell it otherwise. To cache a query, give its operation a cache that includes POST:
import { cacheMiddleware } from 'liaise/middleware'
const getCategory = new Operation<{ id: string }, { category: Category }>({
operation: GET_CATEGORY,
middleware: [cacheMiddleware({ methods: ['POST'] })],
})
Put it on the query operations you want cached, never on the client. A client-level cache would also wrap your mutations, and a cached mutation answers the second one with the first one’s response.
GraphQL errors
A 2xx response with { errors: [...] } is an error. It has kind: 'http', the response’s own status, and the GraphQLError[] in error.body. The same if (error) check covers GraphQL errors, HTTP errors and network errors.
GraphQL allows partial success, where one field fails and the rest of the query resolves. That data is kept in error.partialData. result.data stays null whenever error is set, so Result keeps its two clean branches.
const { error } = await graphql.getCategory({ id: '123' })
if (error) {
console.log(error.body) // GraphQLError[]
console.log(error.partialData) // the data the server sent with the errors, or undefined
}
- A 2xx with neither
datanorerrorsis a'parse'error, the same rule as an empty body on REST. That covers an empty body,{},{"data": null}and a JSON root that isn’t an object.error.bodyholds the raw response text, such as''or'{}'. {"data": null, "errors": [...]}is stillkind: 'http', because the errors are checked first. Any partial result is inerror.partialData.
Same as REST, and different
Most of what the guide says about createApi holds for createGraphQL.
The same as REST
- Every call returns a
Result,{ data, error, response, retry }, anderror.kindnames the failure (Handling errors). middlewareruns on the client, theOperationand the call, in the same order. The context has the same shape, so the built-in middleware and yours work unchanged.headersgo on the client, theOperationand the call, and merge by the three levels.onErrorgoes oncreateGraphQLand works as in Reporting errors with onError.fetchOptionsgo oncreateGraphQL, anOperationor a call, and merge as on REST (Cookies and other fetch options).retry()is on everyResult.dedupegoes on theOperation, as in Drop stale calls with dedupe.sharegoes on theOperation, as in Sharing identical requests. Variables must match exactly, key order included. On a mutation, the note there about writes applies.timeoutgoes oncreateGraphQL, theOperationor the call, and the most specific one wins. It is one deadline for the whole call, retries included (Set a deadline with timeout).loggoes oncreateGraphQL, as in Log every call. Each line names the operation, as in[liaise] → POST getCategory https://api.example.com/graphql.getHeaders()is on every operation (getHeaders()).schemagoes on theOperationand validates the response’s wholedata. The response type stays explicit (Validating responses).signalandskipMiddlewarego on the call.
Different from REST
endpointis the full URL of the GraphQL endpoint. It takes the place ofbaseUrl.- Variables always go in the JSON body. There are no path params, no query strings and no
bodyAs. - Every operation is a
POST, withContent-Type: application/jsonunless you set your own. - There is no
responseType. The response is always read as JSON.
Every option is listed under createGraphQL options and Operation options.
Next: Testing your code shows how to test code that calls liaise, against a stub fetch.