Full-Stack Type Safety with Axolotl, Zeus, and Vite
Full-Stack Type Safety with Axolotl, Zeus, and Vite
I've been building GraphQL APIs for years, and the biggest pain has always been keeping types in sync. You write your schema, manually create TypeScript types for resolvers, generate client types, and hope nothing drifts. One typo in a resolver return type and you're debugging runtime errors.
This example shows a different approach - true end-to-end type safety from schema to React components, running on a single server.
The Setup
The yoga-federated example runs everything on one port:
- Backend: Express + GraphQL Yoga with Axolotl-generated types
- Frontend: React + Vite with Zeus-generated GraphQL client
- Both: Same server, same origin
http://localhost:4002/ -> React Frontend (SSR)
http://localhost:4002/graphql -> GraphQL Playground
In development, Vite runs as Express middleware for HMR. In production, Express serves the built frontend. One deployment, zero CORS config.
The @resolver Directive
Here's the schema:
type Todo {
_id: String!
content: String!
done: Boolean
}
type AuthorizedUserMutation {
createTodo(content: String!, secret: Secret): String! @resolver
todoOps(_id: String!): TodoOps! @resolver
}
type Query {
user: AuthorizedUserQuery @resolver
}
The @resolver directive marks which fields need implementation. Fields without it are auto-resolved from the parent object.
type Query {
user: AuthorizedUserQuery @resolver # ← needs code
}
type User {
_id: String! # ← resolved from parent
username: String! # ← resolved from parent
}
This is what makes Axolotl work well with AI assistants. When Copilot or Cursor sees a schema, it knows exactly which fields need resolvers and which don't. No guessing.
Prompt an AI with "Implement all @resolver fields" and it generates the right code because the schema is explicit about what needs implementation.
Run axolotl build and you get typed models:
// Auto-generated src/models.ts
export interface Todo {
_id: string;
content: string;
done?: boolean | undefined | null;
}
export type Models = {
['AuthorizedUserMutation']: {
createTodo: {
args: {
content: string;
secret?: Secret | undefined | null;
};
};
};
};
Write resolvers with full type inference:
// src/todos/resolvers/AuthorizedUserMutation/createTodo.ts
import { createResolvers } from '../../axolotl.js';
export default createResolvers({
AuthorizedUserMutation: {
createTodo: async ([source], { content }) => {
// TypeScript knows `content` is string
// TypeScript knows this must return string
const _id = Math.random().toString(8);
db.todos.push({ owner: src._id, content, _id });
return _id;
},
},
});
Return a number instead of a string and TypeScript complains immediately.
Micro-Federation
The example splits schemas by domain:
src/
├── todos/
│ ├── schema.graphql
│ └── resolvers/
├── users/
│ ├── schema.graphql
│ └── resolvers/
└── resolvers.ts
Axolotl merges them:
// src/resolvers.ts
import { mergeAxolotls } from '@aexol/axolotl-core';
import todosResolvers from '@/src/todos/resolvers/resolvers.js';
import usersResolvers from '@/src/users/resolvers/resolvers.js';
export default mergeAxolotls(todosResolvers, usersResolvers);
Types flow through the merge.
Frontend with Zeus
Zeus generates a typed GraphQL client from the schema:
// frontend/src/api.ts
import { Chain } from '../../src/zeus/index';
export const createGqlClient = (token?: string) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['token'] = token;
}
return Chain('/graphql', { headers });
};
Queries are type-safe:
// frontend/src/hooks/useTodos.ts
const fetchTodos = async () => {
const data = await gql()('query')({
user: {
todos: { _id: true, content: true, done: true },
},
});
// TypeScript knows data.user?.todos is Todo[]
if (data.user?.todos) {
setTodos(data.user.todos);
}
};
Mutations too:
const createTodo = async (content: string) => {
await gql()('mutation')({
user: {
createTodo: [{ content }, true],
},
});
};
The [{ content }, true] syntax means: pass these arguments, return this field. TypeScript validates that content matches the schema.
Express + Vite on One Server
The whole stack runs on one Express server:
// src/index.ts
async function startServer() {
const app = express();
// Mount GraphQL
const { yoga } = adapter({ resolvers, directives });
app.use('/graphql', yoga);
// Development: Vite middleware for HMR
if (!isProduction) {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
app.use(vite.middlewares);
} else {
// Production: serve built assets
app.use(sirv(resolve(__dirname, '../dist/client')));
}
// SSR for all other routes
app.use('*all', async (req, res) => {
const { html } = await render(req.originalUrl);
res.send(html);
});
}
You get:
- HMR in development
- SSR for SEO
- One deployment artifact
- No proxy config
The Type Flow
A todo travels through the system like this:
- Schema defines
createTodo(content: String!): String! - Axolotl generates
args: { content: string }for resolvers - Resolver uses
{ content }with TypeScript support - Zeus generates client types from the same schema
- React hook calls
createTodo: [{ content }, true]with type checking - Response comes back typed
Change the schema, run axolotl build, and TypeScript shows you what broke.
Try It
cd examples/yoga-federated
npm install
npm run dev
Open http://localhost:4002:

Register and you're in and can create todos

GraphQL Playground at http://localhost:4002/graphql:

What I Like About This
After using this pattern, a few things stand out:
- Schema changes propagate everywhere - modify a field, run build, TypeScript tells you what broke on both backend and frontend
- AI assistants actually help - the
@resolverdirective means Copilot knows what to implement without explaining the architecture - One
npm run dev- no separate terminal for frontend, no proxy config, no CORS debugging - SSR just works - good for SEO if you need it
The tradeoff is coupling - frontend and backend deploy together. For most projects that's fine. If you need independent scaling, split them later.
Links
Built with Axolotl + GraphQL Yoga + Zeus by Aexol Studio