Skip to content

Angular generator

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.

Each OpenAPI tag becomes an @Injectable Angular service. Every operation is a readonly property with three flavors:

  • .observable(req, options?) — returns an Observable<T> via HttpClient. Pass { observe: 'response' } to receive Observable<HttpResponse<T>> (headers, status), or { observe: 'events' } for Observable<HttpEvent<T>> (upload/download progress, raw events). See .observable() modes below.
  • .resource(reactiveReq, options?) — returns an HttpResourceRef<T> via Angular’s httpResource. The ref exposes headers(), statusCode(), and progress() as signals.
  • .request(req) — returns the raw CommonRequest for 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.

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 $ref body — preserved as a single nested body: RefName field so the spec’s named type stays referenceable from your code (UpdatePetParams['body'] is the imported UpdatePetRequest).
  • Inline type: object body — 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 as Blob | File. The generated builder materializes them into FormData / URLSearchParams at 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.

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:

rest/pet/get-pet.ts
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() }));

.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.

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.

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.

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.

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.

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.

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,
});

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,
);

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.

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(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.

@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);
}
}

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.

Type-only module. Defines:

  • QueryParamValue — the value types httpParams accepts (string | number | boolean | ReadonlyArray<…>).
  • CommonRequest — the raw request returned by .request(). Extends Angular’s HttpResourceRequest and adds method / url. Use this for custom transports.
  • WithDefault<T>, WithParse<T, TRaw> — mixin shapes for resource options.
  • BaseHttpResourceOptions<T, TRaw>HttpResourceOptions with parse and defaultValue stripped 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.

Runtime module. Exports:

  • OPENAPI_NG_BASE_PATHInjectionToken<string> for the API base.
  • provideOpenapiNg({ basePath })EnvironmentProviders shortcut.
  • httpParams(record) — builds an HttpParams from a record, skipping undefined and flattening arrays (each item becomes a repeated query param).
  • RequestFn<Request, Response> / ZeroArgRequestFn<Response> — the shape of every generated operation property (generic parameters are Request first, Response second); RequestFnVoid<Request> / ZeroArgRequestFnVoid are the void-response variants picked automatically when Response is void.
  • requestFactory — callable for JSON; .blob / .text / .arrayBuffer cover the non-JSON response kinds.

Runtime module bridging generated REST methods into Angular signal-forms async validation. Exports:

  • validateRest(path, requestFn, opts) — wraps validateAsync from @angular/forms/signals and delegates to the generated RequestFn.resource(), so request/response typing flows through without manually constructing an HttpResourceRequest. opts.request builds the typed request from the field context; opts.onSuccess (optional) maps the typed response into form errors; opts.onError (required) handles HTTP failures. Omit onSuccess for the common “HTTP 200 = valid, 4xx = invalid” pattern.
  • RestValidatorOptions<TRequest, TResponse, TValue, TPathKind> — the options bag accepted by validateRest.
  • MapToErrorsFn — re-export from @angular/forms/signals so 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' }),
});

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.

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.