Eden Treaty Type Performance
Eden Treaty gives you end-to-end type safety by typing the client over your whole server. On a large app that type gets expensive — here is why, and how to keep it fast without giving up inference.
#The Symptom
As an app grows, `absolute typecheck` (which runs `tsc`) slows to a crawl and eventually fails with `JavaScript heap out of memory` — even with a large `--max-old-space-size`. Crucially, the app still builds and runs fine: Bun bundles without a full type check, so only the batch type-check gate is affected.
#Why It Happens
Typing the client over the whole server instantiates the params, body, response, and header types of every route in one giant nested type. A batch `tsc` run builds all of it eagerly, and composing many plugins through the `.use()` chain is closer to multiplicative than additive — so cost explodes well before you hit any documented route ceiling.
// The convenient default: one client typed over the WHOLE app.
import { treaty } from '@elysiajs/eden';
import type { Server } from '../../backend/server';
export const server = treaty<Server>(window.location.origin);
// On a large app, `treaty<Server>` instantiates the type of EVERY route at once.
// That is super-linear, and a batch `tsc` (`absolute typecheck`) runs out of memory.Your editor stays fast because the TypeScript language server is lazy — it only instantiates the slice for the file you are editing. It is the eager, whole-program batch check that runs out of memory.
#Fix: Per-Plugin Clients
Elysia's recommendation is to type Eden over a sub-app instead of the whole server. You keep full type safety for those routes; TypeScript just evaluates one plugin's type at a time.
// Elysia's recommendation: type Eden over a SUB-APP, not the whole Server.
// You keep full end-to-end type safety — it just evaluates one plugin's type.
import { treaty } from '@elysiajs/eden';
import type { adminsPlugin } from '../../backend/plugins/adminsPlugin';
// Plugins are `(db) => Elysia<…>`, so ReturnType is the app type.
// `import type` keeps the backend plugin out of the frontend bundle.
export const adminApi = treaty<ReturnType<typeof adminsPlugin>>(url);
await adminApi.api.admin.users.get(); // fully typedTo keep the ergonomic `server.*` surface with zero call-site changes, build one client per plugin and compose them back into the same shape with explicit leaf assignment. Never object-spread an Eden client — it is a URL-building Proxy, so spreading drops the routes at runtime.
// Keep the ergonomic `server.*` surface AND stay fast: build one client per
// plugin, then compose them back into the same shape with EXPLICIT leaf
// assignment. Never object-spread an Eden client — it is a URL-building Proxy,
// so spreading drops every route at runtime.
const admins = treaty<ReturnType<typeof adminsPlugin>>(url);
const athlete = treaty<ReturnType<typeof athletePlugin>>(url);
const coaches = treaty<ReturnType<typeof coachesPlugin>>(url);
export const server = {
admins: admins.admins,
api: {
admin: {
users: admins.api.admin.users,
// hyphenated routes are bracket keys, not dotted:
'invite-codes': coaches.api.admin['invite-codes']
},
athlete: athlete.api.athlete
}
};
// Every existing call site is unchanged and still fully typed:
await server.api.athlete.profile.get();#Fix: Cheap Return Types
Fixing the client is only half of it, and the server entry is where most teams reach for casts. Resist that. The cause is the plugin's own return type: a config-conditional plugin (`config.x ? routes(x) : new Elysia()`) produces a union at every branch, and `.use()` distributes over unions — so the merged type trends toward 2^N and eventually can't even be serialized (`TS7056`). Casting it to `AnyElysia` at each call site hides the symptom but throws the types away.
The cure is to give the plugin an explicit return type that exposes only what consumers actually read. `@absolutejs/auth` is the worked example: its route paths are configurable, so Elysia keys them by `string` (never a literal) — there is no precise route type to expose anyway. What is precise is the typed `protectRoute` context, so `auth()` returns that, and `User` is inferred from your `getUser`. Consumers just `.use(authPlugin)` — no cast, full inference, instant type-check.
// The real fix lives in the PLUGIN, not the call site. A plugin with many
// routes — especially config-conditional ones — has an inferred return type
// that MULTIPLIES: every `config.x ? routes(x) : new Elysia()` is a union,
// and `.use()` distributes over unions, so N optional plugins trend toward
// 2^N. It blows past tsc's serialization limit (TS7056) long before any route
// ceiling. Casting it away at every call site is a workaround; giving the
// plugin an explicit, cheap return type is the cure.
// Expose only what consumers actually read. @absolutejs/auth is the worked
// example: its route PATHS are configurable (authorizeRoute, callbackRoute…),
// so Elysia keys those routes by `string` — there is no literal route type to
// expose anyway. What IS precise is the typed `protectRoute` context, so
// auth() returns that, and User is inferred from your `getUser`:
//
// export const auth = async <User>(
// config: AuthConfig<User> // User inferred from config.getUser
// ) => composed as unknown as AuthInstance<User>; // one internal bridge
// Consumers just mount it — no cast, full inference, instant type-check. Your
// typed Eden surface (the REST plugin Treaty reads) stays fully inferred:
const server = new Elysia()
.use(framework)
.use(authPlugin) // Promise<AuthInstance<User>> — small + typed
.use(apiPlugin(db)); // your Eden surface; stays precisely inferred#Notes
Do not reach for this on a small app — a handful of plugins type-checks fine. Prefer plain `fetch` for one-off or bearer-authenticated calls, since those add nothing to the treaty type. And prefer a cheap, explicit return type at the plugin boundary over a cast at the call site: a cast erases the types for everyone, while a well-chosen return type keeps exactly the surface consumers need and nothing they don't.
This is how the AbsoluteJS route plugins are built, so you never cast them: `@absolutejs/auth` returns its typed `protectRoute` context, `@absolutejs/voice` returns a base Elysia (its Twilio routes are reached by path), and so on. Mount them and keep your own typed surface fully inferred.