Article
Typing Apollo GraphQL Queries with GraphQL-Zeus
At ecoeats, we used a lot of GraphQL. Customers, riders, restaurants, dashboards: different interfaces, the same graph underneath.
One of the things that made that manageable was
graphql-zeus
. It generates TypeScript helpers from your schema, so the editor can complain about your query before a customer does.
The useful trick is that you don't need to replace Apollo Client to use it. Let Zeus describe the operation. Let Apollo handle fetching, caching, and React state.
Updated 10 September 2026: this walkthrough uses the documented Zeus
typedGql
integration and Apollo Client 4 import paths. It is a new illustrative example, not the original ecoeats client. The snippets have not been run against an application here; pin compatible package versions and type-check the generated output in your project.
Type the operation, not the entire database
A GraphQL
Rider
type might have twenty fields. A query selecting
id
and
name
hasn't fetched the other eighteen.
Manually annotating its result with the whole entity type is a very efficient way to persuade TypeScript that missing data exists. We want the result type to follow the selection, including nullability, and the variables to follow the operation.
That's the job of a
TypedDocumentNode
: a GraphQL document carrying TypeScript information about its result and variables. Apollo can infer both from it.
Start with a tiny schema
Save this as
schema.graphql
. It is a fictional delivery schema, not a production API:
type Query {rider(id: ID!): Rider}type Rider {id: ID!name: String!orders: [Order!]!}type Order {id: ID!title: String!}
The schema is the generator's input, not a running server. To fetch data, you'll also need a GraphQL endpoint implementing it. In an existing application, use the schema exported by your server.
Install the client dependencies and generator:
npm install @apollo/client graphql rxjs @graphql-typed-document-node/corenpm install --save-dev graphql-zeusnpx zeus schema.graphql ./src --typedDocumentNode
The examples below assume the generated files are at
src/zeus/index.ts
and
src/zeus/typedDocumentNode.ts
. Check the generator's output for your installed version and adjust the imports if necessary.
Commit a lockfile. The old Zeus Apollo-specific helpers and current typed-document approach aren't examples to mix together indiscriminately.
Describe the query once
Create
src/riderQuery.ts
:
import { $, Selector } from './zeus';import { typedGql } from './zeus/typedDocumentNode';const orderSummary = Selector('Order')({id: true,title: true,});export const GET_RIDER = typedGql('query')({rider: [{ id: $('id', 'ID!') },{id: true,name: true,orders: orderSummary,},],});
Fields without arguments are objects of selections. A field with arguments uses a pair: arguments first, selected fields second.
$('id', 'ID!')
declares a variable reference. It isn't the rider's literal ID, and a plain string
'$id'
isn't a substitute for this helper.
Selector('Order')
gives us a reusable, checked selection. It plays a similar composition role to a fragment; it isn't a separate network request.
The resulting operation is conceptually:
query GetRider($id: ID!) {rider(id: $id) {idnameorders {idtitle}}}
The handwritten name above is for readability; the generated operation need not use that name. Use your installed Zeus version's operation-naming options if names are required for logging or persisted operations.
Keep Apollo doing Apollo things
Create
src/client.ts
:
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';export const client = new ApolloClient({link: new HttpLink({ uri: '/graphql' }),cache: new InMemoryCache(),});
This assumes
/graphql
is your application's endpoint or development proxy. Add your application's authentication link if it requires one. Don't put service credentials in browser code.
Now create
src/App.tsx
:
import { ApolloProvider, useQuery } from '@apollo/client/react';import { client } from './client';import { GET_RIDER } from './riderQuery';function RiderOrders({ riderId }: { riderId: string }) {const { data, loading, error } = useQuery(GET_RIDER, {variables: { id: riderId },errorPolicy: 'none',});if (loading) return <p role="status">Loading rider...</p>;if (error) return <p role="alert">Couldn't load this rider. Try again.</p>;if (!data?.rider) return <p>Rider not found.</p>;const { rider } = data;return (<section><h2>{rider.name}</h2>{rider.orders.length === 0 ? (<p>No orders yet.</p>) : (<ul>{rider.orders.map(order => (<li key={order.id}>{order.title}</li>))}</ul>)}</section>);}export default function App() {return (<ApolloProvider client={client}><RiderOrders riderId="demo-rider" /></ApolloProvider>);}
Use an ID that exists in your server. Notice what's absent: no handwritten
RiderResponse
, no cast of the response, and no generic argument on
useQuery
pretending we know what came back.
The
rider
lookup is nullable in the schema. That isn't a nuisance to cast away; it is a real state the interface needs to handle.
Apollo's default normalized cache generally identifies objects through
__typename
plus
id
or
_id
. Keep stable identifiers in reusable selections and configure
typePolicies
for entities with different keys. Typed results don't decide how pagination or list insertion should update the cache.
Prove the types are doing something
Try three deliberate mistakes, then run your project's TypeScript check:
Select
titel
instead of
title
. It should be rejected against the generated schema.
Omit the required
id
variable. The operation should no longer type-check.
Read an unselected field from an order. It shouldn't magically appear in the result type.
Run generation followed by
tsc --noEmit
in CI. If generated files are committed, also check for unexpected changes after generation. Otherwise, a schema snapshot quietly becoming stale defeats much of the point.
What this doesn't solve
Generated types describe a contract. They don't validate arbitrary network JSON at runtime, prevent a server deployment from breaking old clients, or prove a user has permission to access an entity.
Custom scalars need particular care. A
DateTime
usually crosses the wire as a string; declaring it to be a JavaScript
Date
doesn't construct one. Configure scalar mappings and decoding explicitly where needed.
For GraphQL errors, decide whether the screen can display partial data. This example uses
errorPolicy: 'none'
and treats an operation error as failure. A dashboard that can use partial results needs a different, deliberate UI path.
Is Zeus necessary?
No. Apollo works well with operation types generated from conventional GraphQL documents too. Choose that approach if your team prefers
.graphql
files and an existing code-generation workflow.
I like Zeus when defining and composing a selection in TypeScript feels natural. The win isn't changing clients. It's removing the gap between the fields I asked for and the fields my editor says I have.
One query, one inferred result shape, considerably fewer lies to TypeScript.
References