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/core
npm install --save-dev graphql-zeus
npx 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) {
id
name
orders {
id
title
}
}
}

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

    ☕️ Made with Intenso