Skip to content
Effect 4.0 is here. One ecosystem. Zero dependencies.

GraphQLResolver

Batch lookups through a GraphQL client method with RequestResolver. ordered matches results by position; findById matches by key; aliased repeats an operation under aliases in one request. request turns any of them into (key) => Effect.

Client middleware still run. When a method needs services, such as a middleware's requires, batchKey keeps lookups with different services in separate batches. Compose RequestResolver.setDelay and RequestResolver.batchN with .pipe to control batching.

4 exports Since v4.0.0 Source

Resolvers

Since v4.0.0

Batches calls to a generated query or mutation by repeating the operation under aliases in one request.

When to use

Use when the API has no list-key field, such as GitHub's pull requests by repository and number. Otherwise, ordered or findById send a smaller query.

Details

Use request to get (variables) => Effect. Variables that encode to the same JSON share a slice; each slice uses the operation's result Schema. Throws when the resolver is built if the operation has no document template, if an operation directive references a variable, or if a root selection is a fragment spread or inline fragment, since none of these can be repeated under aliases.

Batches use POST even with queryMethod: "GET". The default batch size is 10; change it with RequestResolver.batchN. Middleware run once per batch, and batchKey works as in ordered. Mock and custom methods are called once per slice instead.

Errors with a matching path[0] fail only that slice, with the alias prefix removed from the error path. Errors without a matching path, transport errors and middleware failures fail the batch.

Gotchas

An error under a non-null root field can null the entire response, failing every slice. GitHub's repository and pullRequest are nullable.

Middleware that hash queries must use request.query, not operation.document. Trusted-document allowlists don't support batching; query hashing does, since the same operation and slice count produce the same document.

Mutations are allowed, but the caller cannot control root-field order.

Signature

declare function aliased<Op extends Any>(method: OperationMethod<Op>, ...__namedParameters: {} extends BatchKey<Op> ? [options?: BatchKey<Op>] : [options: BatchKey<Op>]): RequestResolver<AliasedRequest<Op>>

Example Looking up pull requests by number

import { Effect, Layer, RequestResolver, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
// Generated from:
// query PullRequest($owner: String!, $name: String!, $number: Int!) {
// repository(owner: $owner, name: $name) { pullRequest(number: $number) { title } }
// }
const PullRequest = GraphQL.query("PullRequest", {
document: {
variables: [
{ name: "owner", body: "String!" },
{ name: "name", body: "String!" },
{ name: "number", body: "Int!" }
],
selections: [{
name: "repository",
body: ["(owner:$", "owner", ",name:$", "name", "){pullRequest(number:$", "number", "){title}}"]
}],
fragments: []
},
variables: { owner: Schema.String, name: Schema.String, number: Schema.Int },
result: Schema.Struct({
repository: Schema.NullOr(Schema.Struct({
pullRequest: Schema.NullOr(Schema.Struct({ title: Schema.String }))
}))
})
})
// A stand-in server that answers every aliased slice
const queries: Array<string> = []
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: (request) => {
queries.push(request.query)
const data: Record<string, unknown> = {}
for (const [key, number] of Object.entries(request.variables as Record<string, unknown>)) {
const prefix = /^(_\d+_)number$/.exec(key)?.[1]
if (prefix !== undefined) data[`${prefix}repository`] = { pullRequest: { title: `PR ${number}` } }
}
return Effect.succeed({ body: { data } })
},
subscribe: () => Stream.empty
})
const program = Effect.gen(function*() {
const github = yield* GraphQLClient.make(GraphQLGroup.make(PullRequest))
const PullRequests = GraphQLResolver.aliased(github.PullRequest).pipe(RequestResolver.batchN(20))
const getPullRequest = GraphQLResolver.request(PullRequests)
const results = yield* Effect.forEach(
[1, 2, 3],
(number) => getPullRequest({ owner: "Effect-TS", name: "effect", number }),
{ concurrency: "unbounded" }
)
return [results.map((result) => result.repository?.pullRequest?.title), queries.length]
})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => [["PR 1", "PR 2", "PR 3"], 1]
Since v4.0.0

Batches lookups by key for searches and collections that return results in any order.

Details

Write key before variables to infer the key type from its return type. variables maps batch keys to operation variables. Keys are compared with Equal and Hash. path and typename work as in ordered; key receives the narrowed item type.

Missing keys, null parents, pagination and batchKey behave as in ordered. Any GraphQL error fails the batch with the same ResponseError as a non-partial call, since error paths cannot be matched to keys.

Signature

declare function findById<Op extends Any, K, P extends readonly Array<string>, T extends string = never>(method: OperationMethod<Op>, options: {
readonly key: (item: Narrow<Item<Result<Op>, P>, T>) => K;
readonly path: Path<Result<Op>, P>;
readonly typename?: T;
readonly variables: (keys: readonly Array<K>) => Variables<Op>;
} & BatchKey<Op>): RequestResolver<KeyRequest<K, Narrow<Item<Result<Op>, P>, T>, NoSuchElementError | Error<Op>, Services<Op>>>

Example Finding repositories through search

import { Effect, Layer, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
const Repository = Schema.Struct({ __typename: Schema.Literal("Repository"), nameWithOwner: Schema.String })
const User = Schema.Struct({ __typename: Schema.Literal("User"), login: Schema.String })
const SearchRepos = GraphQL.query("SearchRepos", {
document: "query SearchRepos($query:String!){search(query:$query,type:REPOSITORY,first:100){pageInfo{hasNextPage} nodes{__typename ...on Repository{nameWithOwner} ...on User{login}}}}",
variables: { query: Schema.String },
result: Schema.Struct({
search: Schema.Struct({
pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }),
nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Union([Repository, User]))))
})
})
})
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: (request) => {
const { query } = request.variables as { query: string }
const nodes = query.split(" ").map((term) => ({ __typename: "Repository", nameWithOwner: term.slice(5) }))
return Effect.succeed({ body: { data: { search: { pageInfo: { hasNextPage: false }, nodes: nodes.reverse() } } } })
},
subscribe: () => Stream.empty
})
const program = Effect.gen(function*() {
const client = yield* GraphQLClient.make(GraphQLGroup.make(SearchRepos))
const RepoByName = GraphQLResolver.findById(client.SearchRepos, {
key: (repo) => repo.nameWithOwner,
variables: (names) => ({ query: names.map((name) => `repo:${name}`).join(" ") }),
path: ["search", "nodes"],
typename: "Repository"
})
const repos = yield* Effect.forEach(
["Effect-TS/effect", "Effect-TS/website"],
GraphQLResolver.request(RepoByName),
{ concurrency: "unbounded" }
)
return repos.map((repo) => repo.nameWithOwner)
})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["Effect-TS/effect", "Effect-TS/website"]
Since v4.0.0

Batches lookups for fields such as nodes(ids:) that return one entry per key in input order.

Details

variables maps batch keys to operation variables; annotate its parameter to set the key type. path selects a list through object fields, with autocomplete at each level. For __typename unions, typename narrows the success type and treats other types as missing.

Null entries, typename mismatches and null parents fail with Cause.NoSuchElementError. GraphQL errors whose paths start with [...path, i] fail only entry i; its ResponseError contains those errors and the response status. Other GraphQL errors fail the batch with the same ResponseError as a non-partial call. Transport, encode and decode errors also fail the batch. A list length mismatch fails with DecodeError.

pageInfo.hasNextPage: true on the list's parent fails the batch with DecodeError to avoid reporting unreturned entries as missing. Set the operation's page size at least as large as RequestResolver.batchN.

A batch uses one lookup's services. Methods requiring services also require batchKey; only lookups with keys equal under Equal share a batch. Return the value that distinguishes those services, for example (context) => Context.get(context, CurrentUser).tenantId. Methods without services use one batch and do not accept batchKey.

Gotchas

Don't return the whole context from batchKey. It also holds the caller's span, so most lookups would get a batch of their own.

A middleware that reads a Context.Reference with a default value doesn't add it to the method's services. batchKey isn't required for it, and lookups with different values can share a batch.

Signature

declare function ordered<Op extends Any, K, P extends readonly Array<string>, T extends string = never>(method: OperationMethod<Op>, options: {
readonly path: Path<Result<Op>, P>;
readonly typename?: T;
readonly variables: (keys: readonly Array<K>) => Variables<Op>;
} & BatchKey<Op>): RequestResolver<KeyRequest<K, Narrow<Item<Result<Op>, P>, T>, NoSuchElementError | Error<Op>, Services<Op>>>

Example

import { Cause, Effect, Layer, RequestResolver, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
const Repository = Schema.Struct({ __typename: Schema.Literal("Repository"), nameWithOwner: Schema.String })
const User = Schema.Struct({ __typename: Schema.Literal("User"), login: Schema.String })
const RepoNodes = GraphQL.query("RepoNodes", {
document: "query RepoNodes($ids:[ID!]!){nodes(ids:$ids){__typename ...on Repository{nameWithOwner} ...on User{login}}}",
variables: { ids: Schema.Array(Schema.String) },
result: Schema.Struct({ nodes: Schema.Array(Schema.NullOr(Schema.Union([Repository, User]))) })
})
const store: Record<string, unknown> = {
R_1: { __typename: "Repository", nameWithOwner: "Effect-TS/effect" },
U_1: { __typename: "User", login: "tim" }
}
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: (request) => {
const { ids } = request.variables as { ids: Array<string> }
return Effect.succeed({ body: { data: { nodes: ids.map((id) => store[id] ?? null) } } })
},
subscribe: () => Stream.empty
})
const program = Effect.gen(function*() {
const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoNodes))
const RepoById = GraphQLResolver.ordered(client.RepoNodes, {
variables: (ids: ReadonlyArray<string>) => ({ ids }),
path: ["nodes"],
typename: "Repository"
}).pipe(RequestResolver.batchN(100))
const getRepo = GraphQLResolver.request(RepoById)
return yield* Effect.forEach(
["R_1", "U_1", "R_2"],
(id) => getRepo(id).pipe(
Effect.map((repo) => repo.nameWithOwner),
Effect.catchIf(Cause.isNoSuchElementError, () => Effect.succeed("missing"))
),
{ concurrency: "unbounded" }
)
})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["Effect-TS/effect", "missing", "missing"]

Running

Since v4.0.0

Looks up a key with an ordered, findById or aliased resolver, using request(resolver)(key) or request(key, resolver).

Details

Concurrent lookups are batched. Keys equal under Equal and Hash are sent once per batch and share the result.

Signature

declare const request: {
<K, A, E, R>(resolver: RequestResolver<KeyRequest<K, A, E, R>>): (key: K) => Effect<A, E, R>;
<K, A, E, R>(key: K, resolver: RequestResolver<KeyRequest<K, A, E, R>>): Effect<A, E, R>;
}

Example Looking up repositories by id

import { Effect, Layer, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol, GraphQLResolver } from "effect/graphql"
const RepoNodes = GraphQL.query("RepoNodes", {
document: "query RepoNodes($ids:[ID!]!){nodes(ids:$ids){... on Repository{name}}}",
variables: { ids: Schema.Array(Schema.String) },
result: Schema.Struct({ nodes: Schema.Array(Schema.NullOr(Schema.Struct({ name: Schema.String }))) })
})
let calls = 0
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: (request) => {
calls++
const { ids } = request.variables as { ids: Array<string> }
return Effect.succeed({ body: { data: { nodes: ids.map((id) => ({ name: `repo-${id}` })) } } })
},
subscribe: () => Stream.empty
})
const program = Effect.gen(function*() {
const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoNodes))
const getRepo = GraphQLResolver.request(GraphQLResolver.ordered(client.RepoNodes, {
variables: (ids: ReadonlyArray<string>) => ({ ids }),
path: ["nodes"]
}))
const repos = yield* Effect.forEach(["1", "2", "1"], getRepo, { concurrency: "unbounded" })
return [repos.map((repo) => repo.name), calls]
})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => [["repo-1", "repo-2", "repo-1"], 1]