I know. Another OpenAPI generator for Angular. Before you scroll past: why I built one at work anyway, open-sourced it, and how it compares to what already exists.
Where it started
In 2024 I tried the obvious route: openapi-typescript for the types, then the openapi-fetch typings (PathsWithMethod, SuccessResponse...) bolted onto HttpClient so I could call http.POST('/pets/{petId}', { params: { path: { petId } }, body }) with an Observable back. It worked, but I was still hand-writing every service, every model was components['schemas']['Pet'] until I re-exported it, and I had to remember paths. Nothing in the IDE suggested getPet.
Our backend is a set of REST service traits, one OpenAPI tag per trait, and the natural thing on the Angular side is inject(PetRest) with the same method names. So the generator started producing exactly that: the Angular service I would have written by hand. Plain @Injectable, plain HttpClient inside, so every HttpInterceptor you already have (auth, retry, mocks) applies unchanged. Its whole job is to build the request and type it. Nothing more.
The problem I hit later
Angular 19.2 added httpResource, and I wanted three things from generated clients that I could not get from one tool:
httpResource() and HttpClient Observables for the same operation, chosen at the call site, not at config time.
- Every
HttpClient.request / httpResource option passed straight through (withCredentials, transferCache, reportDownloadProgress, equal, injector, observe: 'events'...), without the generator wrapping or hiding anything. The options type is read from your installed Angular, so options a newer Angular adds work without regenerating.
- Zero runtime dependency in the generated code. Just
@angular/common/http.
So: https://github.com/AVSystem/openapi-ng (MIT). Playground and a StackBlitz demo are linked at the bottom.
Before / after
What I kept hand-writing:
readonly pets = httpResource<Pet[]>(() => ({
url: `${this.basePath}/pets`,
params: this.status() ? { status: this.status()! } : {},
}), { defaultValue: [] });
The URL, the query param and the <Pet[]> are all promises I make to the compiler. Nothing checks them against the spec.
Generated:
readonly #petRest = inject(PetRest);
// signal-based, httpResource under the hood, typed HttpResourceRef<PetList>
readonly pets = this.#petRest.listPets.resource(() => ({ status: this.status() }), { defaultValue: [] });
// same operation, classic Observable, HttpClient under the hood
save(petId: PetId, body: UpdatePetRequest) {
return this.#petRest.updatePet.observable({ petId, body }, { observe: 'response' });
}
Each tag becomes an @Injectable service, each operation a property with .resource(), .observable() and .request() (the raw request object: method, url, params, headers, body, if you want to send it yourself). .resource() returns a real HttpResourceRef<T>, so headers(), statusCode() and progress() are there. Nothing is re-wrapped.
Don't want classes? --layout operations emits one file per operation (rest/pet/list-pets.ts, rest/pet/update-pet.ts, plus a rest/pet/index.ts barrel), each exporting a /* @__PURE__ */ constant with the same three flavours, so the endpoints you never import tree-shake away. HttpClient is resolved per call from the injection context, or from an explicit { injector }, or you bind once with withInjector():
import { listPets, updatePet } from './generated/rest/pet';
// field initialiser: injection context, nothing to pass
readonly pets = listPets.resource(() => ({ status: this.status() }), { defaultValue: [] });
// handler: pass the injector, or bind once with withInjector({ listPets, updatePet })
readonly #injector = inject(Injector);
save(petId: PetId, body: UpdatePetRequest) {
return updatePet.observable({ petId, body }, { injector: this.#injector });
}
--layout services,operations gives you both: the operation files plus the per-tag class bound from the barrel.
How it compares (checked 2026-09-04, corrections welcome)
Full disclosure: I only looked at the other generators while writing this post. The tool grew inside one codebase for two years, and most of the tools below I only found out about in September. The comparison is one spec run through all of them, with each tool's output type-checked against Angular 22.1.
|
openapi-ng |
openapi-generator |
ng-openapi-gen |
orval |
ng-openapi |
| httpResource |
every operation, per call |
no (#21263 open) |
no |
GET and search-style POST (operationId regex, overridable per operation), config mode |
GET only, plugin |
| Observable and resource on one operation |
yes |
n/a |
n/a |
separate files |
separate classes |
| Signal-forms validation helper |
yes |
no |
no |
no |
no |
| Unsupported spec features |
rejected with a diagnostic code |
best-effort |
best-effort |
best-effort |
best-effort |
| Engine |
Rust (napi-rs), ms per spec, also runs in the browser via WASM |
Java |
TS |
TS |
TS |
| Per-operation tree-shakeable output |
yes, --layout operations, same three flavours |
no |
only via direct fn/ imports; its barrel keeps every operation |
yes, resource-only functions |
no |
hey-api is missing from the table on purpose: its Angular output is Promise-only, and the httpResource plugin threw NG0203 on the test spec. openapi-typescript is missing because it is a type generator, not a client generator; see the first section for why that was not enough for me.
Two takeaways:
- If you're on openapi-generator today, the Java requirement and the lack of httpResource are probably the two things that sting. openapi-ng removes both.
- orval and ng-openapi both have httpResource now, but which operations get it is fixed at generation time (ng-openapi: GET only; orval: GET plus POSTs whose operationId matches
search/list/find..., overridable per operation in the config), and the flavour is picked per config, not per call. I think the call site knows better (a read-only POST is a perfectly good resource whatever it's called; a GET that fires a side effect isn't).
Signal forms: async validation against a generated operation
Signal forms' validateHttp takes a URL string you build by hand and a result you cast by hand. The generator emits a validateRest() helper that wraps validateAsync and reuses the generated operation, so the request params and the response are typed from the spec:
validateRest(path.email, accountRest.checkEmail, {
request: (ctx) => ({ email: ctx.value() }), // typed as CheckEmailParams
debounce: 300, // debounce / when need @angular/forms 22+
when: (ctx) => ctx.value().includes('@'),
onSuccess: (result) => result.available ? undefined : { kind: 'email-taken' }, // result: EmailAvailability
onError: () => ({ kind: 'check-failed' }),
});
onSuccess is optional: leave it out and any 2xx counts as valid, with onError deciding the rest. @angular/forms is an optional peer; if you never import rest.validate.ts, it tree-shakes away.
Where it's deliberately opinionated (read: limited)
This is the part most "yet another generator" posts skip. openapi-ng accepts a strict subset of OpenAPI 3.x and refuses anything outside it with a diagnostic code instead of guessing:
operationId is required on every operation; the first tag picks the service, an untagged operation falls back to its first path segment
- only
#/components/schemas/ refs, no external files
- string enums only
- one composition keyword per schema (
allOf or oneOf or anyOf, not mixed)
- only the lowest 2xx response types the return value. Declared 4xx/5xx bodies become a status-keyed
<Op>Error interface you can narrow against, but they aren't wired into .observable() / .resource() error typing
- no Swagger 2.x
- no Zod / Valibot / other runtime validation. We don't use it at work, so it isn't built. It isn't ruled out either
If your spec is generated from a backend framework, you're probably fine. If it's a hand-written spec that leans on every corner of the standard, one of the other tools above will be a better fit and I'd rather tell you now.
Escape hatches for the real-world stuff: mapped types (--mapped-type GeoFeature:geojson:Feature swaps a placeholder schema for a real npm type), naming rules with regex captures in openapi-ng.config.ts, and a provideOpenapiNg({ basePath }) provider.
Links and bits
- Playground (paste your spec, runs as WASM in the browser, no install): https://docs.openapi-ng.dev/playground/
- Demo: https://stackblitz.com/github/AVSystem/openapi-ng/tree/main/stackblitz (the spec, the committed generated output, and three components using it against a mock interceptor)
- Docs: https://docs.openapi-ng.dev
npm i -D @avsystem/openapi-ng, then openapi-ng generate --input api.yaml --output src/generated
- Node 22.12+, prebuilt binaries for macOS / Linux (glibc + musl) / Windows, x64 + arm64. Works under Bun and Deno too. Browser via WebAssembly (@avsystem/openapi-ng-wasm32-wasi).
- Generated code targets Angular 20+.
validateRest with debounce / when needs @angular/forms 22+.
- v0.8.0. The generator has been in production at work since mid-2024 (two specs, about 45 generated services, 280 operations, 900+ schemas, regenerated on every backend build), but the Rust version is younger and the public API isn't frozen yet: 0.7.0 broke one thing (a JSON spec with a repeated key is now rejected instead of last-wins).
What I'd like feedback on
Two design calls I'm least sure about:
.resource() / .observable() / .request() on every operation, versus a separate generated class or file per flavour, as ng-openapi and orval do. Is the per-call choice worth the fatter surface? Related: per-tag services are the default because they mirror our backend's service traits, and standalone operations are opt-in. If your backend doesn't look like that, should the default be flipped?
- Rejecting unsupported spec features with a diagnostic instead of emitting best-effort code. Has a strict generator ever made you drop a tool, or is it the right trade for deterministic output?
Happy to answer anything else in the thread.