End-to-End Type Safety with Eden Treaty
January 25, 2025 · Eden Stack Team
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 runtimeYou 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 clientBoth 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
| Feature | OpenAPI | GraphQL | tRPC | Eden Treaty |
|---|---|---|---|---|
| Type generation | Required | Required | None | None |
| Build step | Yes | Yes | No | No |
| REST semantics | Yes | No | No | Yes |
| Schema file | Yes | Yes | No | No |
| Bundle size | Medium | Large | Small | Tiny |
| Learning curve | Medium | High | Medium | Low |
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.