ngx-eden
v0.2.0
Published
Eden Treaty alternative for Angular — end-to-end typesafe Elysia client running on Angular's HttpClient
Maintainers
Readme
ngx-eden
Eden Treaty alternative for Angular — end-to-end typesafe Elysia client running on Angular's
HttpClient
ngx-eden turns the type of your Elysia app into a callable client, exactly like
@elysiajs/eden's Treaty, but every call is an
HttpClient.request() returning an Observable. Interceptors, HttpContext, withCredentials,
SSR TransferState, toSignal() and httpResource() all work because nothing bypasses Angular's
HTTP stack.
const users = await firstValueFrom(api.users.get({ query: { limit: 10 } }))
// ^? User[]
api.users({ id: 1 }).patch({ name: 'Ada' })
// ^? { name?: string; email?: string }ngx-eden vs @elysiajs/eden
| | ngx-eden | @elysiajs/eden (treaty) |
| ----------------------- | --------------------------------------------------- | ------------------------------- |
| Transport | Angular HttpClient.request() | fetch |
| Return type | Observable<Success> (errors in the error channel) | Promise<{ data, error }> |
| { data, error } style | opt-in via safe() | always |
| HTTP interceptors | yes, all provideHttpClient() interceptors run | no (onRequest / onResponse) |
| SSR TransferState | yes, via withHttpTransferCacheOptions() | no |
| Signal support | toSignal(), edenResource() → httpResource() | manual |
| DI / config | provideNgxEden() + createEden() | treaty(domain, config) |
| Path params | api.users({ id }).get() | same |
| Files | auto FormData | same |
| SSE / streaming | not supported (v1) | AsyncGenerator |
| WebSocket | not supported | subscribe() |
Install
bun add ngx-eden
# peer deps
bun add @angular/common @angular/core rxjs
bun add -d elysia # optional peer, only needed to type your server's `App`Requires Angular 20, 21 or 22 (>=20.0.0 <23.0.0; httpResource is still evolving, so newer majors are
added after testing), Elysia 1.4+ (when you import the server's App type) and strict: true.
The published declarations do not import elysia.
Setup
1. Export your app type from the Elysia server
// server/src/app.ts
export const app = new Elysia()
.get('/users', () => users, { query: t.Object({ limit: t.Optional(t.Number()) }) })
.get('/users/:id', ({ params }) => users.find(params.id), {
params: t.Object({ id: t.Number() }),
response: { 200: UserSchema, 404: t.Object({ message: t.String() }) },
})
export type App = typeof app2. Provide the config next to provideHttpClient()
// app.config.ts
import { provideHttpClient, withFetch } from '@angular/common/http'
import { provideNgxEden } from 'ngx-eden'
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch()), // add your interceptors here
provideNgxEden({ baseUrl: 'https://api.example.com' }),
],
}3. Create the client inside an injection context
// api.service.ts
import { Injectable } from '@angular/core'
import type { App } from 'server'
import { createEden } from 'ngx-eden'
@Injectable({ providedIn: 'root' })
export class Api {
readonly client = createEden<App>()
}createEden() calls inject() internally, so use it in a constructor, a field initializer, or
runInInjectionContext(). Pass { baseUrl } to override the provided config per client.
Usage
Calls
const api = inject(Api).client
api.users.get() // Observable<User[]>
api.users.get({ query: { limit: 5 } }) // typed query
api.users.post({ name: 'Ada', email: '[email protected]' }) // typed body
api.users({ id: 1 }).get() // path param, required
api.users({ id: 1 }).delete() // Observable<void>
api.secure.get({ headers: { authorization: 'Bearer …' } }) // required when the schema says so
api.posts({ postId: 1 }).comments({ commentId: 2 }).get()
api.echo.get()
api.echo({ name: 'x' }).get() // optional param `/echo/:name?`Rules mirror Eden:
get/headtake(options?); everything else takes(body?, options?).bodyandoptionsare optional when every field in the schema is optional.- The success type is the
200 | 201 | 202 | 204response, then any other 2xx, then the union of everything declared. - A body that contains a
File,BloborFileListis sent asFormData; other objects as JSON. - Query values: arrays repeat the key,
Datebecomes ISO, objects areJSON.stringifyed,null/undefinedare skipped.
Options
Every call accepts, besides typed query and headers:
| option | forwarded to |
| ----------------- | ----------------------------------------------------------------- |
| withCredentials | HttpClient.request() |
| context | HttpContext for interceptors |
| reportProgress | HttpClient.request() |
| responseType | 'auto' (default), 'json', 'text', 'blob', 'arraybuffer' |
'auto' reads the body as text and decodes it by Content-Type the way Eden does: JSON when the
server says so, otherwise numbers / booleans / JSON-looking strings, else the raw string.
Errors
Non-2xx responses go to the RxJS error channel as Angular's HttpErrorResponse, with error
already decoded. This includes responseType: 'blob' and 'arraybuffer': the error body is read as
UTF-8 text and decoded by Content-Type like any other body, so the server's JSON error is
available as error.error. For the Eden style, use safe():
import { safe } from 'ngx-eden'
safe(api.users({ id }).get()).subscribe((result) => {
if (result.error) {
result.error.status // 404 | 422
result.error.value // { message: string } | ValidationError
return
}
result.data // User
})
api.users.get().pipe(safe()) // operator form, error typed as EdenError<number, unknown>The call form keeps the route's declared error union. Elysia adds 422 to every route with a schema.
For async code, unwrap() awaits the first value and rejects with an EdenRequestError
(status, value, message API request failed with status <status>):
import { EdenRequestError, unwrap } from 'ngx-eden'
try {
const user = await unwrap(api.users({ id }).get()) // User
} catch (error) {
if (error instanceof EdenRequestError) {
error.status // number
error.value // unknown: narrow it before use
}
}EdenRequestError.value is unknown. TypeScript does not type promise rejections, so the route's
error union cannot reach a catch clause, and value can also be a network or decode error the
route never declared. When you need the typed union, use safe() instead.
Failures that never reached the server (network errors, interceptor errors) have status 0 and the
original error as value.
A success response whose body does not decode (for example malformed JSON under
Content-Type: application/json) is also an HttpErrorResponse. It keeps the real status (such
as 200), and its error is { error, text }, the same shape Angular uses when responseType:
'json' fails to parse.
Response types and dates
Response types are mapped through Serialized<T>: JSON has no Date, so Date fields are typed
string (ISO), recursively through arrays, tuples and objects, for success bodies and error values.
Blob, File, ArrayBuffer and primitives are left alone. There is no date revival.
Signals
import { toSignal } from '@angular/core/rxjs-interop'
import { edenResource } from 'ngx-eden'
@Component({/* … */})
export class Users {
private api = inject(Api).client
users = toSignal(this.api.users.get(), { initialValue: [] })
id = signal(1)
// httpResource() under the hood; re-fetches when `id` changes; value typed as User | undefined
user = edenResource(() => this.api.users({ id: this.id() }).get.$request())
}The request's responseType picks the httpResource variant: json → httpResource, text →
httpResource.text (raw string, no heuristics), blob → httpResource.blob, arraybuffer →
httpResource.arrayBuffer, auto (default) → text decoded by Content-Type with the client's decoder,
so both return the same value for the same response.
The variant is fixed when the resource is created, so responseType is static per resource. It is
read once, untracked, from the first request; if that request can be undefined, pass
{ responseType } in the options (it wins over the request's). A later request with another
responseType is sent with the original variant and logs a console error.
$request(...) takes the same arguments as the call and returns the request descriptor
(method, url, params, headers, body, …) without sending it. '~path' gives the
resolved path of any node.
Helper types
import type { EdenData, EdenFailure } from 'ngx-eden'
type User = EdenData<typeof api.users.get> // User[]
type Failure = EdenFailure<typeof api.users.get> // EdenError<422, …>Recipe: monorepo with an auth interceptor
A typical Bun workspace: apps/server (Elysia) and apps/web (Angular) where an interceptor
already rebases /api/... onto the API origin and attaches the bearer token.
Keep the interceptor in charge. Create the client with an empty base URL so requests leave
as /api/... and flow through the same interceptor chain as plain HttpClient calls. Doing it
in the service (not provideNgxEden()) also means every TestBed that already has
provideHttpClient() keeps working:
@Injectable({ providedIn: 'root' })
export class Api {
readonly client = createEden<App>({ baseUrl: '' })
}Import the server type from declarations, not source. Server code usually compiles under
its own options (bun types, .ts import extensions) that the Angular project does not share,
and pulling the whole server graph into every front-end build is slow. Emit .d.ts once
instead; skipLibCheck in the web project ignores the server's ambient types:
// apps/server/tsconfig.types.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist/types",
"rootDir": "src",
},
"include": ["src"],
}// apps/server/package.json
{
"types": "dist/types/index.d.ts",
"exports": { ".": { "types": "./dist/types/index.d.ts", "default": "./src/index.ts" } },
"scripts": {
"build:types": "tsc -p tsconfig.types.json",
"dev:types": "tsc -p tsconfig.types.json --watch --preserveWatchOutput",
},
}// apps/web/package.json
{
"devDependencies": { "server": "workspace:*", "elysia": "^1.4.30" },
"scripts": {
"pretypecheck": "bun run --filter server build:types",
"prebuild": "bun run --filter server build:types",
},
}Run dev:types next to the dev servers and a route change on the server becomes a compile
error in the web app a second later.
Replace hand-written response types.
export type Directory = EdenData<Api['client']['api']['directory']['get']>SSR
Because requests flow through HttpClient, provideClientHydration(withHttpTransferCacheOptions())
caches server-side responses and replays them on the client with no extra work.
Limitations
- No Server-Sent Events / streaming responses and no WebSocket
subscribe()—HttpClientdoes not stream bodies. - No date revival: ISO strings stay strings (Angular parses JSON as-is); the types say
string. - A path segment named like an HTTP method (
get,post,put,patch,delete,head,options), e.g./items/delete, is treated as a method call, soapi.items.delete()sendsDELETE /items. Such routes cannot be reached through the proxy; callHttpClientdirectly. - Path parameter values
.and..throw, because they would climb out of their segment. - Wildcard routes are reachable as
api['*'].
Acknowledgements
ngx-eden is written from scratch, but its API and type-level design follow
@elysiajs/eden (Treaty) by
saltyAom, MIT licensed: the callable route tree,
path-parameter calls, optional body / options rules, 2xx-first response selection,
query serialization and FormData conversion all mirror Eden's behaviour so switching
between the two feels familiar. Thank you for the excellent reference.
License
MIT
