Angular generator
Models
Section titled “Models”Schemas become typed TypeScript — interfaces for objects, type aliases for references, and multi-line string unions for enums:
export interface Pet { id: PetId; name: string; nickname?: string; status: PetStatus; tags: Tag[];}
export type PetId = string;
export type PetList = Pet[];
export type PetStatus = 'available' | 'pending' | 'sold';Properties are sorted alphabetically. Optional fields get ?. All
output is deterministic.
Services
Section titled “Services”Each OpenAPI tag becomes an @Injectable Angular service. Every
operation is a readonly property with three flavors:
.observable(req, options?)— returns anObservable<T>viaHttpClient. Pass{ observe: 'response' }to receiveObservable<HttpResponse<T>>(headers, status), or{ observe: 'events' }forObservable<HttpEvent<T>>(upload/download progress, raw events). See.observable()modes below..resource(reactiveReq, options?)— returns anHttpResourceRef<T>via Angular’shttpResource. The ref exposesheaders(),statusCode(), andprogress()as signals..request(req)— returns the rawCommonRequestfor custom use
@Injectable({ providedIn: 'root',})export class PetRest { readonly listPets = requestFactory.zeroArg<PetList>(() => ({ method: 'GET', url: `/pets`, }));
readonly getPet = requestFactory<GetPetParams, Pet>((request: GetPetParams) => { const { petId } = request; return { method: 'GET', url: `/pets/${encodeURIComponent(petId)}`, }; });
readonly updatePet = requestFactory<UpdatePetParams, Pet>( (request: UpdatePetParams) => { const { petId, includeHistory, body } = request; return { method: 'POST', url: `/pets/${encodeURIComponent(petId)}`, params: httpParams({ includeHistory }), body: body, }; }, );}Operations with no request parameters use requestFactory.zeroArg<T> (or
.zeroArg.blob / .zeroArg.text / .zeroArg.arrayBuffer for non-JSON
responses); parameterised operations use the top-level
requestFactory<Req, Res> (with the same .blob / .text /
.arrayBuffer siblings). HttpClient is injected internally by the
runtime — services do not declare it.
Request interface shape
Section titled “Request interface shape”Path params and query params always sit at the top of the request interface. The body’s surface follows a smart-flatten rule keyed on how the spec author wrote the schema:
- Named
$refbody — preserved as a single nestedbody: RefNamefield so the spec’s named type stays referenceable from your code (UpdatePetParams['body']is the importedUpdatePetRequest). - Inline
type: objectbody — hoisted to top-level fields next to path/query, matching the spec’s authorial signal of “these are just parameters, not a named DTO”. - Form bodies (
multipart/form-data,application/x-www-form-urlencoded) — always hoist their fields to top-level; binary fields surface asBlob | File. The generated builder materializes them intoFormData/URLSearchParamsat runtime. - Scalar/array JSON bodies — kept nested as
body: T(there is no property structure to hoist).
// `updatePet` — body is `$ref: UpdatePetRequest`, so it nests.export interface UpdatePetParams { petId: PetId; // path param includeHistory?: boolean; // query param body: UpdatePetRequest; // ref body, nested}
// `decide` — body is an inline `{ csvImportId, doImport }` object, so// its properties hoist to top-level.export interface DecideParams { csvImportId: CsvImportId; doImport: boolean;}The escape hatch in either direction is in the spec: hoist an inline
body to components/schemas (give it a name) to switch to a nested
body, or inline a named schema’s contents at the operation site to
hoist its properties. Hoisted properties that collide with a path or
query parameter name are rejected at codegen with
E_POLICY_VIOLATION / field-collision — rename the offender or
hoist the body schema to a $ref so it nests under body instead.
Standalone operations
Section titled “Standalone operations”Set layout: ['operations'] (or --layout operations) and every
operation becomes its own file under rest/<group>/, exporting one
constant built with defineOperation. The builder and the
<Op>Params / <Op>Error interfaces are the same text the service
class carries; only the wrapper differs, and there are no classes:
import { defineOperation } from '../../rest.util';import type { Pet, PetId } from '../../model';
export const getPet = /* @__PURE__ */ defineOperation<GetPetParams, Pet>( 'getPet', (request: GetPetParams) => { const { petId } = request; return { method: 'GET', url: `/pets/${encodeURIComponent(petId)}`, }; },);
export interface GetPetParams { petId: PetId;}rest/<group>/index.ts re-exports every file in the
group. Import it as a namespace and only the members you touch reach
the bundle: the /* @__PURE__ */ annotation lets esbuild (the Angular
CLI’s bundler) and Rollup drop the operations you never read.
import * as pets from './rest/pet';
readonly pet = pets.getPet.resource(() => ({ petId: this.selectedId() }));Calling a standalone operation
Section titled “Calling a standalone operation”.observable() and .resource() resolve HttpClient and the base
path per call, from options.injector or else from the current
injection context,
the same contract resource and httpResource follow:
// Field initializer, constructor, guard, validateAsync factory: no injector needed.readonly pets = listPets.resource({ defaultValue: [] });
// Event handler, effect, rxResource stream: pass the injector.readonly #injector = inject(Injector);update(petId: PetId) { updatePet.observable({ petId, status: 'sold' }, { injector: this.#injector }).subscribe();}An explicit { injector } also wins over a withInjector() binding.
Calling either outside an injection context without an injector
throws openapi-ng: listPets.observable() was called outside an injection context in dev builds, with Angular’s NG0203 attached as
cause.
.request() needs neither. Without options it returns the builder
output as written, with the spec-relative URL, so unit tests and mock
handlers can use it with no Angular involved. Pass { injector } to
get the URL with the base path prepended. The bound form below always
prepends it.
Binding once: .withInjector()
Section titled “Binding once: .withInjector()”op.withInjector(injector?) resolves DI once and returns the bound
form: exactly the RequestFn a service class property is, so handlers
call it without passing an injector each time. The argument defaults
to inject(Injector), so the zero-argument call must run in an
injection context:
readonly createPet = createPet.withInjector();
save() { this.createPet.observable({ body: this.draft() }).subscribe();}For several operations, the free withInjector from rest.util binds
a record in one line. The result exposes exactly the operations named
and none of them has withInjector:
import { withInjector } from './rest.util';
readonly #api = withInjector({ createPet, deletePet, listPets });Pass a literal, not the namespace import: withInjector(pets) works
but retains every operation in the group. To get the whole group, list
services in layout as well and inject the class instead.
Both layouts together
Section titled “Both layouts together”layout: ['services', 'operations'] emits the operation files and
barrels plus the per-tag classes, each property bound from the barrel:
import { Injectable } from '@angular/core';import * as ops from './pet';
@Injectable({ providedIn: 'root',})export class PetRest { readonly delete = ops.delete.withInjector(); readonly listPets = ops.listPets.withInjector();}
export type { DeleteParams, ListPetsParams } from './pet';PetRest.listPets has the same type with or without operations in
the list, and the class file re-exports the Params / Error
interfaces, so adding operations changes the file tree and nothing
that compiles against the class.
Reserved names
Section titled “Reserved names”A method name that is a reserved word (delete is the common one once
naming rules strip a tag prefix) is declared as delete_ and exported
under its real name. ops.delete works on the namespace import; a
direct import aliases it:
import { delete as deletePet } from './rest/pet/delete';Two names cannot be standalone operations: default, which the barrel
would expose as ops.default, and index, whose file is the barrel
itself. The generator rejects both with E_POLICY_VIOLATION /
reserved-identifier and points at naming.methodName. Two method
names that kebab-case to one file name (delete and delete_) are
rejected under naming-resolution.
validateRest accepts a standalone operation or a bound one as its
second argument.
Configuring the base path
Section titled “Configuring the base path”rest.util.ts exports an OPENAPI_NG_BASE_PATH injection token and a
matching provideOpenapiNg helper. Provide it once at bootstrap and
every service prepends it to the generated relative URL automatically
(the prefix is joined with a single /, regardless of trailing/leading
slashes):
import { ApplicationConfig } from '@angular/core';import { provideHttpClient } from '@angular/common/http';import { provideOpenapiNg } from './generated/rest.util';
export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(), provideOpenapiNg({ basePath: 'https://api.example.com' }), ],};Skip the provider and requests fall back to the spec-relative URL
(/pets, /pets/{id}, …) — useful in dev with a proxy.
.resource() — typed httpResource
Section titled “.resource() — typed httpResource”Generated services delegate to Angular’s
httpResource, so
.resource() accepts every option httpResource accepts — request
transforms, equality, injector, and so on. The generator only adds
strong typing on top.
The signature uses overloads keyed on whether you pass defaultValue
and/or parse:
| Options | Return type |
|---|---|
| (none) | HttpResourceRef<T | undefined> |
{ defaultValue } |
HttpResourceRef<T> |
{ parse } |
HttpResourceRef<U | undefined> |
{ defaultValue, parse } |
HttpResourceRef<U> |
…where T is the spec-declared response type and U is whatever
parse returns.
Why parse matters here
Section titled “Why parse matters here”Angular’s stock httpResource<TResult>(req, { parse }) types parse
as (raw: unknown) => TResult — you take an unknown blob and prove
it’s TResult. The generated .resource() does better: raw is
typed as the spec’s response type, so parse becomes an honest
transformation rather than a runtime cast.
import { PetRest } from './generated/rest/pet.rest';import type { Pet } from './generated/model';
@Component({/* ... */})export class PetList { readonly #petRest = inject(PetRest);
// raw: Pet, return: PetSummary — both fully typed, no `as` needed. protected readonly summary = this.#petRest.getPet.resource( () => ({ petId: this.selectedId() }), { defaultValue: { id: '', label: '—' } satisfies PetSummary, parse: raw => ({ id: raw.id, label: raw.name }), }, );}
interface PetSummary { id: string; label: string;}Pass any of the standard httpResource options the same way — for
example a defaultValue, an equal comparator, or an injector:
this.#petRest.listPets.resource({ defaultValue: [], equal: (a, b) => a.length === b.length,});Reactive request gating
Section titled “Reactive request gating”The reactive request callback may return undefined to skip the call
(matching httpResource’s convention):
this.#petRest.getPet.resource(() => this.selectedId() ? { petId: this.selectedId() } : undefined,);Chaining resources (Angular 22+)
Section titled “Chaining resources (Angular 22+)”On Angular 22+ the callback receives httpResource’s params context, so
a request can
chain
off any other Resource — the dependent resource mirrors its source’s
idle/loading/error status until it resolves. This pairs naturally with
the experimental
debounced, which turns
a signal into a Resource that resolves once the value settles:
protected readonly petId = signal('');readonly #debouncedPetId = debounced(this.petId, 300);
protected readonly pet = this.#petRest.getPet.resource(({ chain }) => { const petId = chain(this.#debouncedPetId); return petId ? { petId } : undefined;});While the user types, pet reports loading instead of firing a
request per keystroke; the request goes out once the input settles (and
is non-empty).
The parameter’s type, ResourceRequestContext, is derived from your
installed Angular version: ResourceParamsContext on 22+, unknown on
20/21 (which never pass a context). Zero-arg callbacks work on every
version.
Response metadata via the ref
Section titled “Response metadata via the ref”HttpResourceRef exposes the response envelope as Angular signals on
the returned ref — no observe/response juggling required:
protected readonly petResource = this.#petRest.getPet.resource(() => ({ petId: this.selectedId() }));
// Read inside an effect, computed, or template binding:this.petResource.headers(); // Signal<HttpHeaders | undefined>this.petResource.statusCode(); // Signal<number | undefined>this.petResource.progress(); // Signal<HttpProgressEvent | undefined>Use these for download progress UI, status-code-driven branching, or reading caching/correlation headers without dropping out of the signal-based flow.
.observable() modes
Section titled “.observable() modes”.observable(req) returns Observable<T> by default — the response
body, decoded according to the operation’s content type. Pass an
options object to switch observation modes or to forward extra
HttpClient.request configuration:
this.#petRest.updatePet.observable(req);this.#petRest.updatePet.observable(req, { observe: 'response' });this.#petRest.updatePet.observable(req, { observe: 'events', reportProgress: true });| Options | Return type |
|---|---|
(none) / { observe: 'body' } |
Observable<T> |
{ observe: 'response' } |
Observable<HttpResponse<T>> |
{ observe: 'events' } |
Observable<HttpEvent<T>> |
The options bag mirrors HttpClient.request’s options minus the
fields the generator already supplies — body, params, headers,
and responseType are baked in from the operation, so the type
rejects them. Everything else is forwarded: withCredentials,
reportProgress, transferCache, context, keepalive, and the
Fetch-related options (redirect, mode, credentials, priority,
cache, timeout).
For void-response operations (204 No Content), the same overloads
still apply — Observable<HttpResponse<void>> is meaningful when you
need Location, ETag, or trace headers from a POST / PUT /
DELETE.
Using a service
Section titled “Using a service”@Component({/* ... */})export class PetList { readonly #petRest = inject(PetRest);
// As an Observable protected readonly pets$ = this.#petRest.listPets.observable();
// As an HttpResource (reactive, signal-based) protected readonly petsResource = this.#petRest.listPets.resource({ defaultValue: [], });
// With parameters protected readonly petResource = this.#petRest.getPet.resource(() => ({ petId: this.selectedId(), }));
// Imperative call protected update(petId: PetId) { this.#petRest.updatePet .observable({ petId, status: 'sold', tagIds: [1, 2], }) .subscribe(); }
// Raw CommonRequest — for custom transports, logging, etc. protected debug(petId: PetId) { const req = this.#petRest.getPet.request({ petId }); console.log(req.method, req.url, req.params); }}Non-JSON responses
Section titled “Non-JSON responses”When an operation declares a response content type other than JSON,
the generator emits the same three flavors but routes them through
the matching httpResource factory. The requestFactory symbol
itself is a callable for JSON, with sibling factories for the other
kinds:
| Response kind | Factory | Emitted return type |
|---|---|---|
json |
requestFactory(...) |
Observable<T> / HttpResourceRef<T> |
blob |
requestFactory.blob(...) |
Observable<Blob> / HttpResourceRef<Blob> |
text |
requestFactory.text(...) |
Observable<string> / HttpResourceRef<string> |
arrayBuffer |
requestFactory.arrayBuffer(...) |
Observable<ArrayBuffer> / HttpResourceRef<ArrayBuffer> |
The picker uses the response’s declared content type; you can override
the mapping per content type with the responseTypeMapping option (CLI:
not currently exposed; Node: generate({ responseTypeMapping: [...] })).
What rest.model.ts / rest.util.ts / rest.validate.ts ship
Section titled “What rest.model.ts / rest.util.ts / rest.validate.ts ship”The generator emits three hand-written helper files alongside the
service. They are stable and worth knowing because your code can
import from them directly. rest.model.ts and rest.util.ts are
dependency-free; rest.validate.ts imports from
@angular/forms/signals and is tree-shaken when unused.
rest.model.ts
Section titled “rest.model.ts”Type-only module. Defines:
QueryParamValue— the value typeshttpParamsaccepts (string | number | boolean | ReadonlyArray<…>).CommonRequest— the raw request returned by.request(). Extends Angular’sHttpResourceRequestand addsmethod/url. Use this for custom transports.WithDefault<T>,WithParse<T, TRaw>— mixin shapes for resource options.BaseHttpResourceOptions<T, TRaw>—HttpResourceOptionswithparseanddefaultValuestripped out (so the generator can put back better-typed versions).BaseHttpResourceOptionsWithDefault<T, TRaw>/…WithParse<T, TRaw>/…WithDefaultAndParse<T, TRaw>— the four permutations consumed by the.resource()overloads.HttpResourceOptionsUnion<T, TRaw>— union over all four.
rest.util.ts
Section titled “rest.util.ts”Runtime module. Exports:
OPENAPI_NG_BASE_PATH—InjectionToken<string>for the API base.provideOpenapiNg({ basePath })—EnvironmentProvidersshortcut.httpParams(record)— builds anHttpParamsfrom a record, skippingundefinedand flattening arrays (each item becomes a repeated query param).RequestFn<Request, Response>/ZeroArgRequestFn<Response>— the shape of every generated operation property (generic parameters areRequestfirst,Responsesecond);RequestFnVoid<Request>/ZeroArgRequestFnVoidare the void-response variants picked automatically whenResponseisvoid.requestFactory— callable for JSON;.blob/.text/.arrayBuffercover the non-JSON response kinds.
rest.validate.ts
Section titled “rest.validate.ts”Runtime module bridging generated REST methods into Angular signal-forms async validation. Exports:
validateRest(path, requestFn, opts)— wrapsvalidateAsyncfrom@angular/forms/signalsand delegates to the generatedRequestFn.resource(), so request/response typing flows through without manually constructing anHttpResourceRequest.opts.requestbuilds the typed request from the field context;opts.onSuccess(optional) maps the typed response into form errors;opts.onError(required) handles HTTP failures. OmitonSuccessfor the common “HTTP 200 = valid, 4xx = invalid” pattern.RestValidatorOptions<TRequest, TResponse, TValue, TPathKind>— the options bag accepted byvalidateRest.MapToErrorsFn— re-export from@angular/forms/signalsso callers need at most one form-signals import.
@angular/forms is an optional peer dependency. Add it to your
project only if you import from rest.validate.ts; the file
tree-shakes away when unused.
import { validateRest } from './generated/rest.validate';
validateRest(emailPath, accountRest.checkEmail, { request: ctx => ({ email: ctx.value() }), onError: () => ({ kind: 'email-taken' }),});debounce and when
Section titled “debounce and when”opts.debounce and opts.when are passed straight through to
validateAsync, so they behave exactly as documented for Angular’s own
validateHttp. Use debounce to avoid firing a request on every
keystroke, and when to skip the validation entirely for some field
states:
validateRest(emailPath, accountRest.checkEmail, { request: ctx => ({ email: ctx.value() }), debounce: 300, when: ctx => ctx.value().length > 2, onError: () => ({ kind: 'email-taken' }),});debounce accepts a duration in milliseconds or a debouncer function
receiving the request value, and when a predicate over the field
context. Neither shape is restated in the emitted file: everything
validateRest does not supply itself is inherited from Angular’s
AsyncValidatorOptions, so the typed request flows into debounce as
DebounceTimer<TRequest | undefined>, and any option a later Angular
version adds passes through without regenerating.
Both options require @angular/forms 22 or newer, where
validateAsync gained them. On @angular/forms 21 the emitted file
still compiles — the two keys are simply absent from
RestValidatorOptions, so passing them is a compile error rather than a
silently ignored option.
Assumptions and limitations
Section titled “Assumptions and limitations”openapi-ng accepts a focused subset of OpenAPI 3.x. See the
Limitations reference for the full list of
required fields, supported schema shapes, and what’s out of scope.