Full-Stack Type Safety with Axolotl, Zeus, and Vite

by Artur Czemiel

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:

  1. Schema defines createTodo(content: String!): String!
  2. Axolotl generates args: { content: string } for resolvers
  3. Resolver uses { content } with TypeScript support
  4. Zeus generates client types from the same schema
  5. React hook calls createTodo: [{ content }, true] with type checking
  6. 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:

  1. Schema changes propagate everywhere - modify a field, run build, TypeScript tells you what broke on both backend and frontend
  2. AI assistants actually help - the @resolver directive means Copilot knows what to implement without explaining the architecture
  3. One npm run dev - no separate terminal for frontend, no proxy config, no CORS debugging
  4. 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

Looking for a technology partner?

Let's talk about your project

Take the first steps in your digital transformation for better