tarek.kharsa

home / writing

Effect8 min read

Why We Moved Our GraphQL Client from gql.tada to effect/graphql

Why we switched, what we got, and how we moved without breaking the app.

At work I build a TanStack Start app. Its server runs on Effect 4, and almost all of its content comes from a headless CMS over GraphQL.

For a long time our GraphQL layer was gql.tada for typed queries, plus a client we wrote ourselves. This month we replaced both with effect/graphql, the GraphQL module that shipped in Effect 4.0.4.

Here is why we moved, what we got, what it cost, and how we did it without breaking the app.

In short: gql.tada gave us types, but nothing checked the data at runtime. effect/graphql checks every response, gives us one clear error type, and makes testing easy. We moved in four small steps, and the app kept working after each one.

The numbers

38,501lines deleted (12,340 added)
31,424of them were one generated type file
10broken test fixtures found on day one
0call sites changed when we swapped the client
46readFragment() calls removed
39queries and fragments moved

Before and after

gql.tada

Queries live in template strings inside TypeScript.

Types come from the schema, but the response is cast to them.

Our own client for errors, retries, mocks and metrics.

effect/graphql

Queries live in .graphql files, and a generator writes the code.

Every response is decoded with a Schema, so bad data fails right away.

Errors, middleware, a test transport and batching are built in.

Why we moved

1. The types were a promise, not a check

With gql.tada you write a query, and TypeScript works out its type. It feels great:

before.ts
import { graphql, type ResultOf } from "gql.tada"

const BookByIsbn = graphql(`
  query BookByIsbn($isbn: Isbn!) {
    book(isbn: $isbn) { id title publishedAt }
  }
`)

type Book = ResultOf<typeof BookByIsbn>

But at runtime, our client got JSON back and simply told TypeScript "trust me, this is a Book". When the CMS changed a field, nothing failed where the data came in. We found out later, as an undefined somewhere on a page.

2. A 31,424-line file in git

To keep the editor fast, gql.tada writes a cache with the types of every query. Ours had grown to 31,424 lines. If it went missing, the type check failed with TS2589: "Type instantiation is excessively deep".

3. TypeScript 7

gql.tada needs the old TypeScript compiler API. Its TypeScript 7 issue was closed as not planned, so we had to keep two TypeScript versions installed side by side.

4. Too much code of our own

Error mapping, retries, partial results and the test mock were all ours. Those are exactly the things a library should own.

What we got

Responses are checked, not trusted

You write each query in a .graphql file and point the generator at your schema:

graphql.config.ts
import { defineConfig } from "@effect/graphql-generator/Config"

export default defineConfig({
  schema: "./schema.graphql",
  documents: ["documents/**/*.graphql"],
  scalars: { Isbn: "./scalars.ts#Isbn" }
})
documents/BookByIsbn.graphql
query BookByIsbn($isbn: Isbn!) {
  book(isbn: $isbn) {
    id
    title
    publishedAt
  }
}

The generator turns each file into a Schema plus an operation you can call:

library.ts
import * as Context from "effect/Context"
import { GraphQLClient, GraphQLGroup } from "effect/graphql"
import { BookByIsbn } from "./documents/BookByIsbn.graphql.ts"

class Library extends Context.Service<Library>()("Library", {
  make: GraphQLClient.make(GraphQLGroup.make(BookByIsbn))
}) {}

// Effect<BookByIsbn.Result, GraphQLClientError, Library>
const book = Library.use((library) => library.BookByIsbn({ isbn }))

Now the response is decoded. If the CMS sends something the query didn't ask for, you get a DecodeError with the exact path, at the moment the data arrives. Custom scalars like Isbn become your own branded types.

One error type

Every failure is a GraphQLClientError with a reason, so turning it into your own errors is one switch:

errors.ts
switch (error.reason._tag) {
  case "ResponseError":  // the server answered with GraphQL errors
  case "TransportError": // network, timeout or an HTTP status
  case "DecodeError":    // the answer didn't match the query
}

Errors carry the HTTP status and headers. A response error also tells you its retryAfter and whether it isRetryable. And with { partial: true } you get { data, errors }, so one broken field doesn't take down a whole page.

Tracing and metrics in one place

Spans, metrics and logs now live in one small middleware that runs for every query. It's 36 lines, and it sees the operation name and the result.

Tests that behave like production

The client sends requests through a GraphQLProtocol. In production that's GraphQLProtocol.layerHttp({ url }), on top of Effect's HttpClient, so our existing retry and timeout rules still apply. In tests, it's a few lines:

scripted.ts
const scripted = (fixtures: Record<string, unknown>) =>
  Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
    execute: (request) =>
      Effect.succeed({ body: { data: fixtures[request.operationName ?? ""] } }),
    subscribe: () => Stream.empty
  })

Test data still goes through the real decoding and the real middleware. The first time we ran our mocked backend this way, 10 fixtures failed: 8 were missing a __typename that the real CMS always sends, and 2 had timestamps in the wrong unit. Our old mock had been returning bad data for months, and nobody noticed.

There is also GraphQLClient.layerTest, where you return values directly. It's quick, but it skips decoding and middleware, so we only use the scripted protocol.

Batching

GraphQLResolver.aliased turns many lookups into one request. Each lookup becomes an aliased copy of the query:

batching.ts
const library = yield* Library
const Books = GraphQLResolver.aliased(library.BookByIsbn).pipe(RequestResolver.batchN(20))
const getBook = GraphQLResolver.request(Books)

// 4 lookups, 1 request
yield* Effect.forEach(isbns, (isbn) => getBook({ isbn }), { concurrency: "unbounded" })

We had one hand-built query string doing exactly this. It's the next thing to go.


How we moved

We split the move into steps that each left the app working, so every pull request could be reviewed and shipped on its own.

  1. Upgrade Effect to 4.0.4The GraphQL module is marked experimental, so we pin the exact version.
  2. Swap the client, keep the functionOur query(document, variables) function kept its signature, so none of the ~200 call sites changed. Inside, it now uses effect/graphql and maps its errors to ours in one place.
  3. Replace the mockA scripted GraphQLProtocol keyed by operation name, plus fixes for the 10 fixtures it caught.
  4. Move to the generator, one area at a timeResultOf<typeof Doc> became Doc.Result, fragments became plain types, and all 46 readFragment() calls went away. Then we removed gql.tada.

All four steps are merged, and gql.tada is gone.

We also added a graphqlgen --check step to our checks. It fails when someone edits a query but forgets to run the generator.

What it cost

It wasn't all wins, and I'd rather tell you now:

  • Type checking is not faster. With TypeScript 7, a full check takes about the same 4 to 5 seconds as before. But it creates 17% more types (1.32M → 1.55M) and uses 14% more memory (2.6 GB → 2.9 GB). The generated Schemas are real classes, and TypeScript has to check them all.
  • Some generated code repeats. The generator wrote 7,017 lines and 702 Schema classes. 211 of those classes (30%) are exact copies of a class in another file, like a small { html5 } rich-text type that appears 19 times.
  • Our client got bigger, not smaller. It grew from 190 to 306 lines, because it now handles each error reason on its own instead of lumping them together. I think that's the right trade.
  • No more queries inside TypeScript. gql.tada let you write a query right next to the component that uses it. Now queries live in .graphql files, and you run graphqlgen --watch while you work.

Things that bit us

  • Decide what "success" means yourself. Our own status helper treated a 3xx as OK, so a redirect with a data body could have been cached as valid. A code review caught it. We now require a 2xx, and a test covers 300, 302 and 304.
  • partial: true doesn't mean "anything goes". A null in a non-null field still fails the whole result. Check how your server handles nulls before you switch.
  • Don't call the protocol directly. GraphQLProtocol.execute skips the middleware, so your traces and metrics quietly disappear. Always go through the client.
  • The API changed during the release. Between the preview builds and 4.0.4, request headers became Headers.Headers, and the generator started writing Schema.Opaque classes. Pin exact versions while it's experimental.

Should you move?

If your server already runs on Effect, I think yes. You get checked responses, clear errors and easy tests, and the price is one generator step.

If you don't use Effect, gql.tada is still a great way to get typed queries with no build step. Just remember that its types are a promise, not a check.

Thanks

Tim Smart built effect/graphql in Effect-TS/effect#8979. While it was in review, I tried it against a real app and sent feedback with small reproductions in effect-graphql-feedback: the response status on errors, GET requests, per-call URL params, a test client and batching. Each one was fixed or shipped before the release. The code examples in this post come from that repository.