End-to-End Type Safety with Eden Treaty

January 25, 2025 · Eden Stack Team

typescriptapielysiaeden-treatytype-safety

End-to-End Type Safety with Eden Treaty

One of the most frustrating parts of full-stack development is keeping your API types in sync. You define a response shape on the backend, then manually recreate it on the frontend. When the API changes, TypeScript can't help you—your client code silently breaks at runtime.

Eden Treaty solves this completely.

The Problem: Type Drift

Consider a typical setup with a REST API:

// Backend: Express + TypeScript
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json({ user, lastActive: user.lastActiveAt }); // Renamed from 'lastLogin'
});
 
// Frontend: React + TypeScript
interface User {
  id: string;
  name: string;
  lastLogin: Date; // 💥 Stale type — no longer matches API
}
 
const user = await fetch('/api/users/123').then(r => r.json()) as User;
console.log(user.lastLogin); // undefined at runtime

You could use OpenAPI/Swagger with codegen, GraphQL with type generation, or tRPC. Each has tradeoffs:

  • OpenAPI: Requires maintaining a spec, running generators
  • GraphQL: Heavy runtime, schema definition overhead
  • tRPC: Locked to tRPC ecosystem, no REST semantics

Eden Treaty offers a fourth way: zero-config, zero-generation, full type safety.

How Eden Treaty Works

Elysia exports its API shape as a TypeScript type:

// apps/api/src/index.ts
import { Elysia, t } from "elysia";
 
const app = new Elysia()
  .get("/api/users/:id", async ({ params }) => {
    const user = await db.users.findById(params.id);
    return { user, lastActive: user.lastActiveAt };
  }, {
    params: t.Object({
      id: t.String(),
    }),
  });
 
// Export the type — this is all you need
export type App = typeof app;

On the client, Eden Treaty uses that type to generate a fully typed client:

// apps/mobile/src/lib/api.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "@eden/api";
 
export const api = treaty<App>("https://api.example.com");
 
// Now you have full type safety:
const { data, error } = await api.api.users["123"].get();
 
if (data) {
  console.log(data.user.name);      // ✅ Typed
  console.log(data.lastActive);     // ✅ Typed
  console.log(data.lastLogin);      // ❌ Type error! Property doesn't exist
}

No code generation. No build step. Just types.

Setting Up Eden Treaty

Step 1: Export Your API Type

In your Elysia app, export the app type:

// apps/api/src/index.ts
import { Elysia, t } from "elysia";
 
const app = new Elysia()
  .use(usersRoute)
  .use(projectsRoute)
  .use(chatRoute)
  .listen(3001);
 
export type App = typeof app;

Step 2: Create a Typed Package

Create a package to share the type:

// packages/api/package.json
{
  "name": "@eden/api",
  "exports": {
    ".": "./src/index.ts"
  }
}
// packages/api/src/index.ts
export type { App } from "../../apps/api/src/index.ts";

Step 3: Use in Your Client

// apps/mobile/src/lib/api.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "@eden/api";
 
const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:3001";
 
export const api = treaty<App>(API_URL, {
  fetch: {
    credentials: "include",
  },
});

Calling Your API

Eden Treaty mirrors your route structure as method chains:

// Route: GET /api/projects
const projects = await api.api.projects.get();
 
// Route: GET /api/projects/:id
const project = await api.api.projects["project-123"].get();
 
// Route: POST /api/projects
const newProject = await api.api.projects.post({
  name: "My Project",
  description: "A new project",
});
 
// Route: PATCH /api/projects/:id
const updated = await api.api.projects["project-123"].patch({
  name: "Renamed Project",
});
 
// Route: DELETE /api/projects/:id
await api.api.projects["project-123"].delete();

Every call is fully typed—request bodies, params, and responses.

Handling Responses

Eden Treaty returns { data, error, status }:

const { data, error, status } = await api.api.users.get();
 
if (error) {
  // error is typed based on your Elysia error handlers
  console.error("Failed:", error.message);
  return;
}
 
// data is typed as your route's return type
for (const user of data.users) {
  console.log(user.name, user.email);
}

For more control, you can use .then():

const users = await api.api.users.get()
  .then(res => {
    if (res.error) throw res.error;
    return res.data.users;
  });

Typed Request Bodies

Elysia's type validation maps directly to your client:

// Backend: Elysia with validation
.post(
  "/api/projects",
  async ({ body }) => {
    const project = await db.insert(projects).values(body).returning();
    return { project: project[0] };
  },
  {
    body: t.Object({
      name: t.String({ minLength: 1, maxLength: 100 }),
      description: t.Optional(t.String()),
      isPublic: t.Boolean({ default: false }),
    }),
  }
)
 
// Client: Types are inferred
const { data } = await api.api.projects.post({
  name: "My Project",         // Required, string
  description: "Optional",    // Optional, string
  isPublic: true,             // Optional, boolean (default: false)
  // invalid: "field",        // ❌ Type error!
});

Authentication with Cookies

Eden Treaty supports custom headers for auth:

// apps/mobile/src/lib/api.ts
import { treaty } from "@elysiajs/eden";
import * as SecureStore from "expo-secure-store";
import type { App } from "@eden/api";
 
export const api = treaty<App>(API_URL, {
  fetch: {
    credentials: "include",
  },
  headers: async () => {
    // Get session cookies from secure storage
    const cookies = await SecureStore.getItemAsync("auth_cookies");
    if (!cookies) return {};
    return { Cookie: cookies };
  },
});

Every request now automatically includes auth headers.

Error Handling

Elysia lets you define typed errors:

// Backend
import { Elysia, error } from "elysia";
 
const app = new Elysia()
  .get("/api/projects/:id", async ({ params, set }) => {
    const project = await db.projects.findById(params.id);
    
    if (!project) {
      return error(404, { 
        code: "PROJECT_NOT_FOUND",
        message: "Project not found",
      });
    }
    
    return { project };
  });
 
// Client
const { data, error, status } = await api.api.projects["xyz"].get();
 
if (status === 404) {
  // error is typed as { code: string; message: string }
  console.log(error.code); // "PROJECT_NOT_FOUND"
}

React Query Integration

Eden Treaty pairs beautifully with TanStack Query:

// apps/web/src/hooks/useProjects.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../lib/api";
 
export function useProjects() {
  return useQuery({
    queryKey: ["projects"],
    queryFn: async () => {
      const { data, error } = await api.api.projects.get();
      if (error) throw error;
      return data.projects;
    },
  });
}
 
export function useCreateProject() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (input: { name: string; description?: string }) => {
      const { data, error } = await api.api.projects.post(input);
      if (error) throw error;
      return data.project;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["projects"] });
    },
  });
}
 
// Usage in component
function ProjectList() {
  const { data: projects, isLoading } = useProjects();
  const createProject = useCreateProject();
  
  if (isLoading) return <Spinner />;
  
  return (
    <div>
      {projects?.map(p => <ProjectCard key={p.id} project={p} />)}
      <button onClick={() => createProject.mutate({ name: "New Project" })}>
        Create Project
      </button>
    </div>
  );
}

Real-World Pattern: Shared API Client

In Eden Stack, we structure the API client for sharing between web and mobile:

packages/
├── api/              # Type export
│   └── src/
│       └── index.ts  # export type { App }
apps/
├── web/
│   └── src/lib/api.ts    # Web client
├── mobile/
│   └── src/lib/api.ts    # Mobile client

Both clients import the same type but configure differently:

// Web client
export const api = treaty<App>(import.meta.env.VITE_API_URL);
 
// Mobile client with SecureStore cookies
export const api = treaty<App>(process.env.EXPO_PUBLIC_API_URL, {
  headers: async () => await getAuthCookies(),
});

Why This Beats the Alternatives

FeatureOpenAPIGraphQLtRPCEden Treaty
Type generationRequiredRequiredNoneNone
Build stepYesYesNoNo
REST semanticsYesNoNoYes
Schema fileYesYesNoNo
Bundle sizeMediumLargeSmallTiny
Learning curveMediumHighMediumLow

Eden Treaty gives you:

  • REST semantics — Standard HTTP methods, URLs, status codes
  • Zero codegen — Types flow through TypeScript's inference
  • Tiny bundle — Just a thin fetch wrapper
  • Easy adoption — If you know Elysia, you know Eden Treaty

Common Gotchas

1. Type Updates Require TypeScript Restart

When you change your API, your IDE might not immediately see the new types. Restart the TypeScript server:

  • VS Code: Cmd+Shift+P → "TypeScript: Restart TS Server"

2. Dynamic Route Segments

Use bracket notation for dynamic segments:

// Route: /api/projects/:projectId/files/:fileId
api.api.projects[projectId].files[fileId].get();

3. Query Parameters

Pass query params as a second argument:

// Route: GET /api/projects?status=active&limit=10
api.api.projects.get({
  query: {
    status: "active",
    limit: 10,
  },
});

Wrapping Up

Eden Treaty eliminates the entire category of "API type drift" bugs. Your backend defines the truth, and your clients automatically stay in sync—no generators, no schemas, no friction.

The combination of Elysia + Eden Treaty is what makes Eden Stack's type safety actually usable, not just possible.

Check out the API Integration docs for more details on setting up your routes.

Ready to build with Eden Stack?

One-time payment. Full source code. No lock-in.

View pricing