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

GraphQLClient

A typed client with one method per operation in a GraphQLGroup. Queries and mutations return an Effect; subscriptions return a Stream. The client encodes variables, runs middleware, calls the GraphQLProtocol in context and decodes results.

Retryable subscription failures restart the middleware chain on the subscriptionRetry schedule. pages and items provide cursor pagination. makeTest builds a mock client for tests, and layerTest provides it as a service layer. See GraphQLProtocol for transport options.

21 exports Since v4.0.0 Source

Constants

The default subscription retry schedule: exponential from 500 milliseconds with factor 1.5, capped at 5 seconds, and never shorter than the server's retryAfter.

Signature

declare const defaultSubscriptionRetry: Schedule.Schedule<Duration.Duration, GraphQLClientError>

Constructors

Since v4.0.0

Builds a client from a group, requiring GraphQLProtocol and every middleware service attached to the group or its operations.

Details

Group middleware runs outside operation middleware. Within each level, middleware run in the order attached.

subscriptionRetry decides how a subscription is retried after it fails with a retryable error: usually a TransportError, but also a ResponseError whose response carried a valid Retry-After, including an event with errors on a graphql-sse stream whose response had that header. It defaults to defaultSubscriptionRetry. Fatal errors are never retried, whatever the schedule. A retry starts a new subscription, so the server may send events again that the stream already emitted.

Signature

declare function make<Ops extends Any>(group: GraphQLGroup<Ops>, options?: {
readonly subscriptionRetry?: Schedule<unknown, GraphQLClientError, never, never>;
}): Effect<GraphQLClient<Ops>, never, GraphQLProtocol | Identifier<Middleware<Ops>>>

Example A client as a service

import { Context, Effect, Layer, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
// What the generator emits for viewer.graphql
const Viewer = GraphQL.query("Viewer", {
document: "query Viewer{viewer{login}}",
result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
})
const ViewerGraphQLGroup = GraphQLGroup.make(Viewer)
class GitHub extends Context.Service<GitHub>()("app/GitHub", {
make: GraphQLClient.make(ViewerGraphQLGroup)
}) {}
// A scripted transport; use GraphQLProtocol.layerHttp({ url }) for a real server
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: () => Effect.succeed({ body: { data: { viewer: { login: "tim" } } } }),
subscribe: () => Stream.empty
})
const program = GitHub.use((github) => github.Viewer()).pipe(
Effect.provide(Layer.effect(GitHub, GitHub.make).pipe(Layer.provide(Scripted)))
)
await Effect.runPromise(program) // => { viewer: { login: "tim" } }

Example Partial results

import { Effect, Layer, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
const Repos = GraphQL.query("Repos", {
document: "query Repos{a:repository(owner:\"x\",name:\"a\"){name} b:repository(owner:\"x\",name:\"b\"){name}}",
result: Schema.Struct({
a: Schema.NullOr(Schema.Struct({ name: Schema.String })),
b: Schema.NullOr(Schema.Struct({ name: Schema.String }))
})
})
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: () =>
Effect.succeed({
body: {
data: { a: { name: "a" }, b: null },
errors: [{ message: "Could not resolve to a Repository", path: ["b"] }]
}
}),
subscribe: () => Stream.empty
})
const program = Effect.gen(function*() {
const client = yield* GraphQLClient.make(GraphQLGroup.make(Repos))
// The default call fails with a ResponseError because `errors` is non-empty
const failed = yield* Effect.flip(client.Repos())
// With partial: true the decoded data comes back together with the errors
const { data, errors } = yield* client.Repos(undefined, { partial: true })
return [failed.reason._tag, data.a?.name, errors[0].path?.[0]]
})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["ResponseError", "a", "b"]

Models

Since v4.0.0

A client: one method per operation, keyed by operation name.

Signature

type GraphQLClient<Ops extends GraphQL.Any> = { [Op in Ops]: Method<Op> }
Since v4.0.0

The client method type for an operation, by its kind.

Signature

type Method<Op extends GraphQL.Any> = Op["kind"] extends "subscription" ? SubscriptionMethod<Op> : OperationMethod<Op>
Since v4.0.0

The client method for a query or mutation.

Details

Called as (variables?, options?). Without partial, or with partial: false, it returns the decoded result and fails with a ResponseError on any errors. With partial: true it returns { data, errors }. partial must be a literal true or false, not an arbitrary boolean.

An operation without variables is called as client.Viewer() or, with options, client.Viewer(undefined, { headers }).

headers and urlParams are sent by the HTTP transport, ignored over graphql-ws, and visible to middleware either way as GraphQLRequest.headers and GraphQLRequest.urlParams. Header options accept Headers.Input and are normalized to Headers.Headers before middleware runs. context provides services for this call only, and removes them from the method's requirements.

Signature

interface OperationMethod<Op extends GraphQL.Any> {
<R2 = never>(...args: ArgsWithOptions<Variables<Op>, {
readonly context?: Context<R2>;
readonly headers?: Input;
readonly partial: true;
readonly urlParams?: Input;
}>): Effect<PartialResult<Result<Op>>, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>;
<R2 = never>(...args: Args<Variables<Op>, {
readonly context?: Context<R2>;
readonly headers?: Input;
readonly partial?: false;
readonly urlParams?: Input;
}>): Effect<Result<Op>, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>;
readonly operation: Op;
}

PartialResult

interface
Since v4.0.0

Decoded data and response errors for a { partial: true } call. The errors array may be empty.

Signature

interface PartialResult<A> {
readonly data: A;
readonly errors: readonly Array<{
[key: string]: Json;
readonly extensions?: {
[key: string]: Json;
};
readonly locations?: readonly Array<{
readonly column: number;
readonly line: number;
}>;
readonly message: string;
readonly path?: readonly Array<string | number>;
}>;
}
Since v4.0.0

The client method for a subscription. It returns a Stream of decoded events; an event with errors fails the stream with a ResponseError, unless that error is retryable and the client resubscribes (see make). headers, urlParams and context work as for OperationMethod.

Signature

interface SubscriptionMethod<Op extends GraphQL.Any> {
<R2 = never>(...args: Args<Variables<Op>, {
readonly context?: Context<R2>;
readonly headers?: Input;
readonly urlParams?: Input;
}>): Stream<Result<Op>, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>;
readonly operation: Op;
}

Paging

Connection

interface
Since v4.0.0

What the connection getter of pages must return.

Signature

interface Connection {
readonly pageInfo: PageInfo;
}
Since v4.0.0

The type variables is given when the operation's after variable is required or does not accept a string. The helper leaves after out on the first page and sends the previous endCursor afterwards, so it can only drive an optional variable that takes a string.

Signature

interface InvalidAfterVariable {
readonly "effect/graphql": "GraphQLClient.pages requires the `after` variable to be optional and to accept a string";
}
Since v4.0.0

Like pages, but emits decoded entries from nodes, including null entries. Use Stream.filter(Predicate.isNotNull) to drop them. For connections selecting only edges, use pages with Stream.flatMap.

Signature

declare function items<Op extends Any, Node, R2 = never>(method: OperationMethod<Op>, options: {
readonly connection: (result: Result<Op>) => NodesConnection<Node> | null | undefined;
readonly options?: {
readonly context?: Context<R2>;
readonly headers?: Input;
readonly urlParams?: Input;
};
readonly variables: PagingVariables<Variables<Op>>;
}): Stream<Node, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>

Example Flattening issue numbers across pages

import { Effect, Layer, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", {
document: "query RepoIssues($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:2,after:$after){pageInfo{hasNextPage endCursor}nodes{number}}}}",
variables: { owner: Schema.String, name: Schema.String, after: Schema.optional(Schema.NullOr(Schema.String)) },
result: Schema.Struct({
repository: Schema.NullOr(Schema.Struct({
issues: Schema.Struct({
pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }),
nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Struct({ number: Schema.Int }))))
})
}))
})
})
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: (request) => {
const after = (request.variables as { after?: string }).after
const page = after === undefined
? { pageInfo: { hasNextPage: true, endCursor: "c1" }, nodes: [{ number: 1 }, null] }
: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ number: 3 }] }
return Effect.succeed({ body: { data: { repository: { issues: page } } } })
},
subscribe: () => Stream.empty
})
const program = Effect.gen(function*() {
const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoIssues))
const items = GraphQLClient.items(client.RepoIssues, {
variables: { owner: "Effect-TS", name: "effect" },
connection: (result) => result.repository?.issues
})
return yield* Stream.runCollect(Stream.map(items, (issue) => issue?.number ?? null))
})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => [1, null, 3]
Since v4.0.0

The type variables is given when the operation has no $after variable. Nothing is assignable to it, so the mistake is a compile error at the call.

Signature

interface MissingAfterVariable {
readonly "effect/graphql": "GraphQLClient.pages requires the operation to declare a nullable `$after: String` variable";
}
Since v4.0.0

What the connection getter of items must return: a connection that also selects nodes.

Signature

interface NodesConnection<Node> extends Connection {
readonly nodes: readonly Array<Node> | null;
}

PageInfo

interface
Since v4.0.0

The pageInfo a paged connection must select.

Signature

interface PageInfo {
readonly endCursor: string | null;
readonly hasNextPage: boolean;
}
Since v4.0.0

Pages forward through a cursor connection, emitting one connection per page.

Details

Requires an optional string $after variable (see PagingVariables). The first request omits after; subsequent requests use the previous endCursor. Pages are fetched only when pulled, so Stream.take stops further requests. Middleware runs on each page.

connection extracts the connection from each result. options is forwarded to every call but cannot include partial: errors fail the stream. Its headers accept Headers.Input, just as for operation calls.

The stream ends after a page whose hasNextPage is false. A null connection on the first page gives an empty stream; on a later page it fails with a PaginationError, as does hasNextPage: true with a null or unchanged endCursor.

Root fields that come with every page, such as GitHub's rateLimit, are for middleware to read from the raw ExecutionResult.

Signature

declare function pages<Op extends Any, C extends Connection, R2 = never>(method: OperationMethod<Op>, options: {
readonly connection: (result: Result<Op>) => C | null | undefined;
readonly options?: {
readonly context?: Context<R2>;
readonly headers?: Input;
readonly urlParams?: Input;
};
readonly variables: PagingVariables<Variables<Op>>;
}): Stream<C, Error<Op>, Exclude<Op["variables"]["EncodingServices"], R2> | Exclude<Op["result"]["DecodingServices"], R2> | Exclude<Requires<Middleware<Op>>, R2>>

Example Paging issues, two per page

import { Effect, Layer, Schema, Stream } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", {
document: "query RepoIssues($owner:String!,$name:String!,$after:String){repository(owner:$owner,name:$name){issues(first:2,after:$after){pageInfo{hasNextPage endCursor}nodes{number}}}}",
variables: { owner: Schema.String, name: Schema.String, after: Schema.optional(Schema.NullOr(Schema.String)) },
result: Schema.Struct({
repository: Schema.NullOr(Schema.Struct({
issues: Schema.Struct({
pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }),
nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Struct({ number: Schema.Int }))))
})
}))
})
})
// A scripted server with two pages
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
execute: (request) => {
const after = (request.variables as { after?: string }).after
const page = after === undefined
? { pageInfo: { hasNextPage: true, endCursor: "c1" }, nodes: [{ number: 1 }, { number: 2 }] }
: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ number: 3 }] }
return Effect.succeed({ body: { data: { repository: { issues: page } } } })
},
subscribe: () => Stream.empty
})
const program = Effect.gen(function*() {
const client = yield* GraphQLClient.make(GraphQLGroup.make(RepoIssues))
const pages = GraphQLClient.pages(client.RepoIssues, {
variables: { owner: "Effect-TS", name: "effect" },
connection: (result) => result.repository?.issues
})
return yield* Stream.runCollect(Stream.map(pages, (page) => page.pageInfo.endCursor))
})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => ["c1", null]
Since v4.0.0

The variables accepted by pages and items: the method's variables without after, which the helper supplies.

Details

The operation's after variable must be optional and accept a string. after?: string | null, from a nullable $after: String, and after?: string, from a $after: String! with a default value, are both accepted. A required after gives InvalidAfterVariable, as does one that does not accept a string; no after at all gives MissingAfterVariable.

Signature

type PagingVariables<Variables> = "after" extends keyof Variables ? {} extends Pick<Variables, "after"> ? string extends Variables["after"] ? Omit<Variables, "after"> : InvalidAfterVariable : InvalidAfterVariable : MissingAfterVariable

Testing

Builds a mock layer for a client service using makeTest. Bind the service and group once to expose a layerTest(handlers) helper on the service. The group fixes the operation types before the handlers are checked.

See

  • makeTest for mock behavior and the middleware-services limitation.

Signature

declare function layerTest<I, Service, Ops extends Any>(service: Key<I, Service>, group: GraphQLGroup<Ops> & GraphQLClient<Ops> extends NoInfer<Service> ? unknown : never): (handlers: TestHandlers<Ops>) => Layer<I>

Example Binding a mock layer to a client service

import { Context, Effect, Schema } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", {
document: "query RepoIssues($owner:String!,$name:String!){repository(owner:$owner,name:$name){issues{totalCount}}}",
variables: { owner: Schema.String, name: Schema.String },
result: Schema.Struct({
repository: Schema.NullOr(Schema.Struct({ issues: Schema.Struct({ totalCount: Schema.Int }) }))
})
})
const GitHubGraphQLGroup = GraphQLGroup.make(RepoIssues)
class GitHub extends Context.Service<GitHub>()("app/GitHub", {
make: GraphQLClient.make(GitHubGraphQLGroup)
}) {
static readonly layerTest = GraphQLClient.layerTest(GitHub, GitHubGraphQLGroup)
}
const program = GitHub.use((github) => github.RepoIssues({ owner: "Effect-TS", name: "effect" }))
await Effect.runPromise(program.pipe(Effect.provide(GitHub.layerTest({
RepoIssues: ({ name }) => Effect.succeed({ repository: { issues: { totalCount: name.length } } })
})))) // => { repository: { issues: { totalCount: 6 } } }
Since v4.0.0

Builds a client for tests that answers each operation from a handler, without a protocol.

Details

Handlers receive the caller's variables and options unchanged and return values in their decoded form. Nothing is encoded, decoded or validated, and no middleware runs. Called with { partial: true }, a method returns { data, errors: [] }. Subscriptions are not retried. Every method carries .operation, so pages and items work on it.

Calling an operation without a handler dies with an UnimplementedError defect, so catchTag in the code under test cannot swallow a missing stub.

Methods keep the real client's types, including middleware service requirements. Tests must provide those services even though mocks do not use them.

To test partial data together with errors, or the encode and decode path, provide a scripted GraphQLProtocol layer to make instead.

Signature

declare function makeTest<Ops extends Any>(group: GraphQLGroup<Ops>): (handlers: TestHandlers<Ops>) => GraphQLClient<Ops>

Example Replacing a client service in tests

import { Context, Effect, Layer, Schema } from "effect"
import { GraphQL, GraphQLClient, GraphQLGroup } from "effect/graphql"
const RepoIssues = GraphQL.query("RepoIssues", {
document: "query RepoIssues($owner:String!,$name:String!){repository(owner:$owner,name:$name){issues{totalCount}}}",
variables: { owner: Schema.String, name: Schema.String },
result: Schema.Struct({
repository: Schema.NullOr(Schema.Struct({ issues: Schema.Struct({ totalCount: Schema.Int }) }))
})
})
const GitHubGraphQLGroup = GraphQLGroup.make(RepoIssues)
class GitHub extends Context.Service<GitHub>()("app/GitHub", {
make: GraphQLClient.make(GitHubGraphQLGroup)
}) {}
const GitHubTest = Layer.succeed(
GitHub,
GraphQLClient.makeTest(GitHubGraphQLGroup)({
RepoIssues: ({ name }) => Effect.succeed({ repository: { issues: { totalCount: name.length } } })
})
)
const program = GitHub.use((github) => github.RepoIssues({ owner: "Effect-TS", name: "effect" }))
await Effect.runPromise(program.pipe(Effect.provide(GitHubTest))) // => { repository: { issues: { totalCount: 6 } } }
Since v4.0.0

The options a makeTest handler receives: the headers and urlParams the caller passed, unchanged. Headers accept Headers.Input; unlike real transport requests, test call options are not normalized.

Signature

interface TestCallOptions {
readonly headers?: Input;
readonly urlParams?: Input;
}
Since v4.0.0

A makeTest handler for one operation. Queries and mutations return an Effect and subscriptions a Stream of the decoded result type.

Signature

type TestHandler<Op extends GraphQL.Any> = Op["kind"] extends "subscription" ? (variables: GraphQL.Variables<Op>, options: TestCallOptions) => Stream.Stream<GraphQL.Result<Op>, GraphQL.Error<Op>> : (variables: GraphQL.Variables<Op>, options: TestCallOptions) => Effect.Effect<GraphQL.Result<Op>, GraphQL.Error<Op>>
Since v4.0.0

The handlers given to makeTest, keyed by operation name. Every handler is optional.

Signature

type TestHandlers<Ops extends GraphQL.Any> = { [Op in Ops]: TestHandler<Op> }

Utility Types

Since v4.0.0

Derives the client type for all operations in a GraphQLGroup.

Signature

type FromGroup<Group> = GraphQLClient<GraphQLGroup.Operations<Group>>