Angular
Generate Angular services with HttpClient and httpResource functions from OpenAPI
Generate fully typed Angular services using HttpClient or signal-first
httpResource functions from your OpenAPI specification.
If you want the short version:
- use
httpClientfor classic Angular service-based APIs - use
httpResourcefor signal-first read flows - use
bothwhen you want resource-based reads and service-based writes together - enable
override.angular.runtimeValidationwhen you generate Zod schemas and want runtime response validation
Configuration
Set output.client to angular to enable Orval's Angular generator:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/petstore.ts',
schemas: 'src/api/model',
client: 'angular',
mock: true,
},
input: {
target: './petstore.yaml',
},
},
});output.client = 'angular' selects the Angular generator. After that, use
override.angular.retrievalClient to choose how retrieval-style Angular
operations should be generated. The older override.angular.client key remains
supported as a backward-compatible alias.
Generated Output
By default, the Angular client generates injectable service classes backed by
HttpClient.
Available Angular retrieval modes:
httpClient— keep retrievals as injectable service methods backed byHttpClienthttpResource— generate signal-firsthttpResourcefunctions for retrieval-style operationsboth— generatehttpResourceretrievals and keepHttpClientmethods for imperative request handling
Mutation-style operations such as create, update, delete, and most imperative
POST calls continue to use generated HttpClient service methods by default.
If you need different behavior for a specific operation, use an operation-level
Angular override.
Register the generated service via DI (standalone or module-based apps both work):
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { PetstoreService } from './api/petstore';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(), PetstoreService],
};providedIn applies to generated service classes only. httpResource
functions are plain exports, so you import and call them directly where you
need them.
Choosing the Angular output mode
Use override.angular.retrievalClient to choose the generated Angular
retrieval mode:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/petstore.ts',
schemas: 'src/api/model',
client: 'angular',
override: {
angular: {
retrievalClient: 'httpClient',
},
},
},
input: {
target: './petstore.yaml',
},
},
});If you omit override.angular.retrievalClient (or the legacy
override.angular.client alias), Orval uses the default Angular
HttpClient service-class output.
Which mode should you choose?
override.angular.retrievalClient | What Orval generates | Choose it when |
|---|---|---|
httpClient | Retrievals stay on injectable service classes backed by HttpClient | You want conventional Angular services, imperative request methods, and the broadest compatibility with request options and mutators |
httpResource | Retrieval-style operations become signal-first httpResource functions; mutations still use HttpClient service methods | You want Angular-native signal ergonomics for reads and prefer resource-based data fetching |
both | Service classes plus sibling *.resource.ts retrieval helpers | You want signal-first reads and imperative writes together |
Quick rule of thumb:
- choose
httpClientfor classic Angular service-based APIs - choose
httpResourcefor signal-first read flows - choose
bothwhen your app wants both patterns side-by-side - if you do not set
override.angular.retrievalClient, Orval defaults tohttpClient - create, update, delete, and other mutation-style operations still use
HttpClientmethods by default
Setting the Backend URL
Single API
Use an HTTP interceptor to automatically add the API base URL. In modern standalone Angular apps, a functional interceptor keeps the setup compact:
import { HttpInterceptorFn } from '@angular/common/http';
export const apiInterceptor: HttpInterceptorFn = (req, next) => {
return next(
req.clone({
url: `https://api.example.com${req.url}`,
}),
);
};import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { apiInterceptor } from './api.interceptor';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([apiInterceptor]))],
};An interceptor is global, though: it can only route by sniffing the outgoing
req.url. That falls apart once you generate more than one Angular API into
the same app and sit them behind a gateway or proxy that assigns each API its
own path prefix (or host) — the interceptor has no reliable way to know which
generated client a given request came from.
DI-based base URL composition (multiple APIs / gateway routing)
Set override.angular.baseUrl to compose the base URL for a specific output
through Angular's dependency injection instead of a global interceptor. Unlike
the interceptor, the base URL is resolved per generated API, so a gateway that
maps different generated clients to different upstream paths can be modeled
directly in DI.
apiId is required and explicit — Orval never derives it from the
specification title or file name — so the generated token and helper names
stay stable across regenerations:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/petstore.ts',
schemas: 'src/api/model',
client: 'angular',
override: {
angular: {
baseUrl: { apiId: 'petstore' },
},
},
},
input: {
target: './petstore.yaml',
},
},
});Generated artifacts
With override.angular.baseUrl set, Orval emits a sibling <target>.base-url.ts
file alongside the generated client:
import { InjectionToken, inject, type Provider } from '@angular/core';
export const PETSTORE_SERVER_URL: string = 'http://petstore.swagger.io/v1';
export function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, '');
}
export interface PetstoreBaseUrlResolverContext {
readonly apiId: 'petstore';
readonly serverUrl: string;
}
export type PetstoreBaseUrlResolver = (
context: PetstoreBaseUrlResolverContext,
) => string;
export const PETSTORE_BASE_URL_RESOLVER =
new InjectionToken<PetstoreBaseUrlResolver>('PETSTORE_BASE_URL_RESOLVER', {
providedIn: 'root',
factory: (): PetstoreBaseUrlResolver => (context) => context.serverUrl,
});
export const PETSTORE_BASE_URL = new InjectionToken<string>(
'PETSTORE_BASE_URL',
{
providedIn: 'root',
factory: (): string => {
const resolver = inject(PETSTORE_BASE_URL_RESOLVER);
return normalizeBaseUrl(
resolver({ apiId: 'petstore', serverUrl: PETSTORE_SERVER_URL }),
);
},
},
);
export function providePetstoreBaseUrl(baseUrl: string): Provider {
return { provide: PETSTORE_BASE_URL, useValue: normalizeBaseUrl(baseUrl) };
}
export function providePetstoreBaseUrlResolver(
resolver: PetstoreBaseUrlResolver,
): Provider {
return { provide: PETSTORE_BASE_URL_RESOLVER, useValue: resolver };
}Identifiers are derived from apiId alone (petstore → PETSTORE_* /
Petstore* / providePetstore*), so they stay collision-free when multiple
outputs with different apiIds are generated into the same app. Every
generated HttpClient service method and httpResource function in this
output injects PETSTORE_BASE_URL and prefixes its route with it.
Precedence
PETSTORE_BASE_URL resolves in this order:
- A directly provided value via
providePetstoreBaseUrl(...)— wins outright, the resolver below is never invoked. - A resolver provided via
providePetstoreBaseUrlResolver(...). - The default resolver factory, which just returns the embedded server URL.
- The embedded
PETSTORE_SERVER_URLconstant, resolved at generation time from the specification'sserversfield (empty string''when the specification has noserversentry, which yields relative URLs).
Whatever value wins is passed through normalizeBaseUrl, which strips
trailing slashes ('/api/x/' → '/api/x', 'https://h/' → 'https://h',
'/' → ''). Generated routes always start with /, so plain interpolation
(`${baseUrl}${route}`) can never double up or drop the separator between
them.
Multiple APIs behind one gateway
Because InjectionToken identity is per output, two generated outputs can't
literally share one token instance without a shared runtime package — Orval's
Angular output has no runtime dependency, by design. Instead, share one
resolver function and register it against each output's resolver token.
Type it against a small structural interface so it's assignable to every
generated <Api>BaseUrlResolver, regardless of apiId:
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { providePetstoreBaseUrlResolver } from './api/petstore.base-url';
import { provideInventoryBaseUrlResolver } from './api/inventory.base-url';
interface GatewayContext {
apiId: string;
serverUrl: string;
}
// One registry, one resolver function, shared across every generated API.
const gatewayRoutes: Record<string, string> = {
petstore: '/gateway/petstore',
inventory: '/gateway/inventory',
};
const gatewayResolver = (ctx: GatewayContext): string =>
gatewayRoutes[ctx.apiId] ?? ctx.serverUrl;
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
providePetstoreBaseUrlResolver(gatewayResolver),
provideInventoryBaseUrlResolver(gatewayResolver),
],
};Each output still resolves its own token independently — gatewayResolver
is just dispatched with a different apiId depending on which token invoked
it — so requests from the petstore client and the inventory client can land on
different upstream paths (or hosts) through the same gateway.
Testing
Override the token (or the resolver) in TestBed like any other provider:
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import {
providePetstoreBaseUrl,
providePetstoreBaseUrlResolver,
} from './api/petstore.base-url';
import { PetsService } from './api/pets/pets.service';
describe('PetsService', () => {
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
providePetstoreBaseUrl('/gateway/petstore'),
// or: providePetstoreBaseUrlResolver((ctx) => `/gateway/${ctx.apiId}`),
],
});
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('prefixes requests with the provided base URL', () => {
TestBed.inject(PetsService)
.createPets({ name: 'Rex', tag: 'dog' })
.subscribe();
httpMock.expectOne('/gateway/petstore/v1/pets').flush(null);
});
});The same token, provided once in TestBed, backs both the HttpClient
service and any httpResource functions generated for the same output — so a
single provider override is enough to redirect every request in a test.
httpResource and injection context
Generated httpResource functions read the token with inject() when called
inside an injection context, and fall back to options.injector.get(...) when
an explicit injector is passed — the same rule that already applies to every
other injected dependency in generated httpResource functions:
export function showPetByIdResource(
petId: Signal<string>,
accept?: ShowPetByIdAccept,
version?: Signal<number>,
options?: OrvalHttpResourceOptions<Pet, unknown>,
): HttpResourceRef<Pet | undefined> {
const baseUrl = options?.injector
? options.injector.get(PETSTORE_BASE_URL)
: inject(PETSTORE_BASE_URL);
// ...
}Call these functions during Angular's injection context (a constructor, a
field initializer, or runInInjectionContext), or pass an explicit
injector in options when you can't.
Notes
- Zod runtime validation is unaffected.
override.angular.runtimeValidationkeeps validating responses exactly as before — the base URL token only changes how the request URL is composed, not how the response is parsed. - Custom mutators receive the composed URL. The
${baseUrl}prefix is applied to the route before it's handed togenerateMutatorConfig, so a configuredmutatorsees the same fully composed URL a plainHttpClientcall would use. - MSW mocks stay relative. Mock route matching is unaffected by
override.angular.baseUrl— MSW handlers keep matching on the route path, not the composed base URL. - Mutually exclusive with
output.baseUrl.output.baseUrlbakes a static prefix into every generated route string for all clients; combining it withoverride.angular.baseUrlwould double-prefix (or conflict with) every URL, so Orval throws a config-time error if both are set on the same output. Removeoutput.baseUrland useprovidePetstoreBaseUrlResolverif you need the equivalent of a runtime-configurable prefix. apiIdis always explicit. It's never derived from the specification'sinfo.titleor the target file name, so renaming your spec or output file doesn't silently rename the generated DI tokens.- Only the specification's top-level
serversfield is embedded as the fallback URL. If your specification setsserversper path/operation, the token still falls back to the spec-levelserversentry (selected viaindex/variables), not a per-path override.
httpResource Output (Angular v19.2+)
Enable the httpResource retrieval mode with override.angular.retrievalClient.
This mode targets Angular's httpResource API, which is available in Angular
v19.2+:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/http-resource/petstore.ts',
schemas: 'src/api/model',
client: 'angular',
override: {
angular: {
retrievalClient: 'httpResource',
},
},
},
input: {
target: './petstore.yaml',
},
},
});Generated resource functions use signals and return HttpResourceRef<T>:
export function showPetByIdResource(
petId: Signal<string>,
): HttpResourceRef<Pet | undefined> {
return httpResource<Pet>(() => `/pets/${petId()}`);
}Because Orval returns Angular's native HttpResourceRef, you automatically get
Angular's resource APIs such as hasValue(), status(), error(),
reload(), and the resource snapshot API for advanced composition.
Which operations become httpResource functions?
Orval generates httpResource helpers for retrieval-style operations.
GEToperations are generated as resources- retrieval-style
POSToperations, such as search/list/find/query endpoints, can also be generated as resources - mutation-style operations remain
HttpClientservice methods
If you need to override the default classification for a specific operation, use an operation-level Angular override:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
client: 'angular',
target: 'src/api/petstore.ts',
override: {
angular: {
retrievalClient: 'httpResource',
},
operations: {
searchPets: {
angular: {
retrievalClient: 'httpResource',
},
},
},
},
},
input: {
target: './petstore.yaml',
},
},
});This is especially useful when your API uses POST for retrieval-style
endpoints.
Consume generated resources safely
Guard value() reads with hasValue(). Angular resources throw if you read
value() while the resource is in an error state, so hasValue() is the safe
and recommended gate.
import { computed, signal } from '@angular/core';
import { showPetByIdResource } from './api/http-resource/pets.service';
const petId = signal('1');
const petResource = showPetByIdResource(petId);
const petName = computed(() => {
if (!petResource.hasValue()) {
return undefined;
}
return petResource.value().name;
});Tip
Prefer returning a fallback from a computed() when hasValue() is false
instead of reading value() unconditionally. That keeps loading and error
states safe in templates and component logic.
Multiple content types
When an operation can return multiple response content types, generated
resources may expose an accept parameter and return different result types for
different Accept values.
export function showPetByIdResource(
petId: Signal<string>,
accept: 'application/json',
): HttpResourceRef<Pet | undefined>;
export function showPetByIdResource(
petId: Signal<string>,
accept: 'text/plain',
): HttpResourceRef<string | undefined>;This keeps the generated Angular API aligned with your OpenAPI response matrix, including plain-text, blob, and array-buffer resource variants where needed.
Content-negotiated resources are just as reactive as single-content-type ones:
every signal input — path params, the params query object, version, and
request-body signals — is read inside the httpResource factory, so updating
any of them triggers a refetch. The accept argument itself is a plain value,
not a signal: it only selects which resource variant (httpResource,
.text, or .arrayBuffer) gets created, so to switch content types you
create a new resource rather than mutating a signal.
Tip
Because accept is read once at creation time, don't wrap it in a signal —
call the resource function again with a different accept value if you need
to switch content types at runtime.
both mode
Use both when you want signal-first retrievals and imperative HttpClient
methods in the same generated area:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/petstore.ts',
schemas: 'src/api/model',
client: 'angular',
override: {
angular: {
retrievalClient: 'both',
},
},
},
input: {
target: './petstore.yaml',
},
},
});In both mode, Orval keeps the service class in your main generated file and
emits the retrieval resources in a sibling *.resource.ts file.
import { PetstoreService } from './api/petstore';
import { listPetsResource } from './api/petstore.resource';This separation works well when your app prefers signal-first reads but still needs service methods for writes or imperative request flows.
httpResource options
You can customize generated httpResource calls through
override.angular.httpResource:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/http-resource/petstore.ts',
schemas: {
type: 'zod',
path: 'src/api/model-zod',
},
client: 'angular',
override: {
angular: {
retrievalClient: 'httpResource',
httpResource: {
defaultValue: { id: 'fallback' },
debugName: 'getPetByIdResource',
},
},
},
},
input: {
target: './petstore.yaml',
},
},
});Common options:
defaultValue— initial value exposed while the resource is idle/loadingdebugName— name shown in Angular DevTools
Advanced options:
injector— raw expression passed toHttpResourceOptions.injectorequal— raw expression passed toHttpResourceOptions.equal
For more advanced cases, you can also pass raw expressions for Angular's resource options:
override: {
angular: {
retrievalClient: 'httpResource',
httpResource: {
injector: 'inject(Injector)',
equal: '(a, b) => a?.id === b?.id',
},
},
}Operation-level httpResource options override global ones:
override: {
angular: {
retrievalClient: 'httpResource',
httpResource: {
debugName: 'globalPetResource',
},
},
operations: {
showPetById: {
angular: {
retrievalClient: 'httpResource',
httpResource: {
debugName: 'showPetByIdResource',
},
},
},
},
}Orval also emits a shared helper type for these options. The exact default
generic for TOmitParse depends on the generated file, but the emitted shape
is:
export type OrvalHttpResourceOptions<
TValue,
TRaw = unknown,
TOmitParse extends boolean = false,
> = (TOmitParse extends true
? Omit<HttpResourceOptions<TValue, TRaw>, 'parse'>
: HttpResourceOptions<TValue, TRaw>) &
OrvalHttpResourceRequestExtension;When defaultValue is configured, generated resource overloads return
HttpResourceRef<T> instead of HttpResourceRef<T | undefined>.
Request headers and HttpContext
Generated httpResource helpers can expose the same request-descriptor
surface as the generated HttpClient service methods: OpenAPI in: header
parameters, plus a call-site escape hatch for extra headers and Angular's
HttpContext (for example to flag a request for a global HTTP interceptor).
OpenAPI in: header parameters. When the top-level output.headers option
is enabled, header parameters declared in your spec are generated as a
headers: Signal<XHeaders> argument and forwarded on every request:
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
target: 'src/api/http-resource/petstore.ts',
schemas: 'src/api/model',
client: 'angular',
headers: true, // generate `in: header` parameters
override: {
angular: {
retrievalClient: 'httpResource',
},
},
},
input: {
target: './petstore.yaml',
},
},
});const correlationId = signal('abc-123');
const headers = computed(() => ({ 'X-Correlation-Id': correlationId() }));
listPetsResource(params, headers);Extra headers and HttpContext at the call site. Every generated
httpResource helper's trailing options bag also accepts headers,
context, and request — independent of whether the operation declares any
in: header parameters. This mirrors the HttpClientOptions already
available on generated HttpClient service methods, so you no longer have to
drop down to HttpClient just to attach interceptor policy or a
per-request header.
The extension fields live on the trailing options parameter — the same
bag that carries defaultValue/debugName. Pass every other generated
argument first: path/query signals, an in: header signal when
output.headers is enabled, and the accept argument on multi-content
helpers all come before the options bag.
import { HttpContext } from '@angular/common/http';
import { BYPASS_GLOBAL_ERROR_HANDLER } from './http-context-tokens';
const requestId = signal('req-42');
// operation with a single path param and one content type:
// the options bag is the second argument
const itemEvents = getItemEventsResource(itemId, {
headers: () => ({ 'x-request-id': requestId() }),
context: new HttpContext().set(BYPASS_GLOBAL_ERROR_HANDLER, true),
});
// multi-content operation from the petstore sample
// (signature: petId, accept, version?, options?) — fill every generated
// parameter before the options bag, using `undefined` for optional ones
// you want to skip
const pet = showPetByIdResource(petId, 'application/json', undefined, {
context: new HttpContext().set(BYPASS_GLOBAL_ERROR_HANDLER, true),
});headers— extra headers merged over any generated headers. Accepts either anHttpHeadersinstance / plain record, or a function returning one.context— an AngularHttpContextforwarded to the underlying request. Accepts either anHttpContextinstance or a function returning one.request— a last-resort escape hatch that receives the fully-builtHttpResourceRequestand returns the request Orval actually sends.
Pass the function form (headers: () => ..., context: () => ..., or
request: (req) => ...) whenever the value reads a signal. Orval invokes
these functions inside the httpResource reactive factory, so the
resource automatically reloads when the signals they read change. A plain
object or HttpContext instance is read once per request computation and
does not itself trigger a reload.
Precedence. Generated OpenAPI headers are applied first, extra headers
from the options bag are merged on top (overriding any header with the same
name), and request runs last. For operations with multiple success content
types, the generated Accept header is always re-applied after your
extension so response-branch dispatch (the accept argument) stays correct —
your headers/context/request can add or override any other header, but
never Accept.
Orval emits the extension as a small, reusable surface alongside
OrvalHttpResourceOptions:
export interface OrvalHttpResourceRequestExtension {
headers?: HttpResourceRequest['headers'] | (() => HttpResourceRequest['headers']);
context?: HttpContext | (() => HttpContext);
request?: (request: HttpResourceRequest) => HttpResourceRequest;
}applyOrvalRequestExtension is the emitted helper that applies these fields;
you won't normally call it directly, but you may see it referenced from
httpResource factories in the generated output.
Testing works the same way as any other generated httpResource call — with
HttpTestingController, assert on the intercepted request's headers and
context:
const req = httpMock.expectOne('/items/42/events');
expect(req.request.headers.get('x-request-id')).toBe('req-42');
expect(req.request.context.get(BYPASS_GLOBAL_ERROR_HANDLER)).toBe(true);Mutator compatibility
httpResource output supports request-compatible mutators, but it cannot use
HttpClient-style mutators that require the generated HttpClient instance as
an extra argument. If your mutator depends on that injected HttpClient
parameter, prefer httpClient or both mode for that operation.
httpResource + Zod schema types
When schemas.type is set to zod, Orval can generate Angular httpResource
functions alongside the Zod-backed model files. The generated resources still
return Angular's native HttpResourceRef, so the main best-practice remains the
same: guard value() reads with hasValue() and test loading/error states the
same way you would for any other httpResource.
With override.angular.runtimeValidation: true, generated JSON resources also
emit parse: Schema.parse and expose Zod output types such as PetOutput and
PetsOutput.
Zod is a runtime dependency when you enable schemas.type: 'zod'.
Make sure your app installs zod (for example, zod in dependencies).
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/http-resource-zod/petstore.ts',
schemas: {
type: 'zod',
path: 'src/api/model-zod',
},
client: 'angular',
override: {
angular: {
retrievalClient: 'httpResource',
runtimeValidation: true, // opt-in
},
},
},
input: {
target: './petstore.yaml',
},
},
});Testing generated httpResource functions
Generated resources use Angular's standard HttpClient stack, so test them the
same way you test any other HttpClient-based code: configure
provideHttpClient() before provideHttpClientTesting(), flush requests with
HttpTestingController, then wait for Angular to propagate the new signal
values.
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { PetsPage } from './pets.page';
describe('PetsPage', () => {
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PetsPage],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('renders fetched pets', async () => {
const fixture = TestBed.createComponent(PetsPage);
fixture.detectChanges();
const req = httpMock.expectOne('/v1/pets');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1, name: 'Rex', requiredNullableString: null }]);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Rex');
});
});This is also the right place to verify loading and backend-error behavior for
schemas.type: 'zod' outputs, especially when your component uses guarded
hasValue() checks to avoid unsafe value() reads. When
override.angular.runtimeValidation is enabled, add at least one test that
flushes an invalid JSON payload and asserts that the resource exposes a
ZodError.
Zod Runtime Validation
Angular output supports runtime validation for Zod-backed responses when
schemas.type is set to zod and override.angular.runtimeValidation is
enabled. For JSON model responses, Orval runs Schema.parse() on eligible
responses and catches type mismatches at runtime.
This applies to generated Angular HttpClient services too, not just
httpResource. If you keep the default Angular retrieval mode
(retrievalClient: 'httpClient'), eligible generated GET methods already
pipe JSON model responses through Zod.
Setup
- Set
schemas.typetozodto generate Zod schemas - Set
override.angular.runtimeValidation: trueto enable runtime validation
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
mode: 'tags-split',
target: 'src/api/petstore.ts',
schemas: {
type: 'zod',
path: 'src/api/schemas',
},
client: 'angular',
override: {
angular: {
runtimeValidation: true,
},
},
},
input: {
target: './petstore.yaml',
},
},
});How It Works
For generated HttpClient services, JSON responses are piped through the Zod
schema's .parse() method. Generated httpResource JSON retrievals use the
same validation hook through Angular's HttpResourceOptions.parse.
// Generated code (simplified)
getPet(options?: HttpClientBodyOptions): Observable<PetOutput> {
return this.http
.get<PetOutput>(`/pet`, { observe: 'body' })
.pipe(map((data) => Pet.parse(data)));
}
showPetByIdResource(
petId: Signal<string>,
): HttpResourceRef<PetOutput | undefined> {
return httpResource<PetOutput>(() => `/pet/${petId()}`, {
parse: Pet.parse,
});
}Validation applies to:
- JSON model responses in generated
HttpClientservices - JSON model responses returned by generated
GETmethods - JSON model responses returned by generated mutation methods such as
POST,PUT,PATCH, andDELETE - JSON
httpResourceretrievals with model response types
Validation is skipped for:
- Primitive types (
string,number,boolean,void,unknown) - Custom mutator paths
- Non-JSON content types (text, blob, arrayBuffer)
For generated HttpClient services, runtime validation also applies when the
JSON response body is surfaced through response and response-event observe
flows.
Request bodies are typed, but not auto-parsed
When you generate Zod schemas, Orval also emits request-body schemas such as
CreatePetsBody, plus matching z.input<> / z.output<> types. Generated
Angular HttpClient methods use those types, but they do not automatically
call Schema.parse(body) before sending a request.
That means the current Angular + Zod split is:
- request bodies get generated TypeScript types and reusable Zod schemas
- response bodies get automatic runtime parsing when
override.angular.runtimeValidationis enabled
If you want pre-send validation, validate explicitly before calling the generated client:
const payload = CreatePetsBody.parse(formValue);
return this.petsService.createPets(payload);Use .parse() when you want invalid input to throw immediately, or
.safeParse() when you want to surface validation feedback without throwing.
Request-body readonly guidance
Angular output also supports override.preserveReadonlyRequestBodies to
control how OpenAPI readOnly fields are handled in generated request body
types:
'strip'(default) — recommended for most OpenAPI specs'preserve'— use when your request DTOs are intentionally immutable
export default defineConfig({
petstore: {
output: {
override: {
preserveReadonlyRequestBodies: 'strip',
},
},
},
});'strip' is the best default because OpenAPI readOnly properties are usually
response-oriented. The same guidance applies to both Angular HttpClient and
httpResource output: httpResource can still issue request payloads, so it
should not preserve readonly request fields by default.
Runtime validation support matrix
| Client | Config key | Runtime validation |
|---|---|---|
angular | override.angular.runtimeValidation | ✅ Supported |
angular-query | override.query.runtimeValidation | ✅ Supported |
fetch | override.fetch.runtimeValidation | ✅ Supported |
| custom mutator paths | varies | ⚠️ See #2858 |
Error Handling
When validation fails, Zod throws a ZodError.
For HttpClient services, handle it in your subscription, effect, or error
interceptor:
this.petService.getPet().subscribe({
next: (pet) => console.log('Validated pet:', pet),
error: (err) => {
if (err.name === 'ZodError') {
console.error('Response validation failed:', err.issues);
}
},
});For generated httpResource helpers, read the error through the resource's
error() signal and keep value() reads guarded with hasValue().
Query parameter filtering
Angular's HttpParams only accepts primitives (string, number, boolean,
and arrays of those). Anything else — objects, arrays of objects, null,
undefined — is rejected or silently stringified to "[object Object]". To
make HttpParams safe, the generated Angular client passes every query
parameter through an internal filterParams helper before handing it to
HttpClient.
Default behaviour (schema-aware passthrough)
- Primitive params keep the existing nullish-stripping, with one
exception: a param the spec declares both
requiredandnullable. Silently dropping a required param would send a request that violates the OpenAPI contract, so it is never simply removed. Without aparamsSerializer, anullvalue for such a param is sent as an empty string (?key=) so the key still reaches the wire. With aparamsSerializerconfigured, the literalnullis passed through for the serializer to encode instead. Optional nullable params are still dropped whennull— only required-nullable params get this treatment. See issue #3712. - Schema-declared object params are serialized according to the OpenAPI
parameter's
style/explode(see Object query parameters below) when noparamsSerializeris configured. When aparamsSerializeris configured, the object is instead passed through untouched — that serializer becomes the consumer responsible for turning the raw object into somethingHttpParamscan send. - Arrays of objects are passed through untouched only when a
paramsSerializeris configured; without one (or aparamsFilter, see below) there is no consumer that can handle a raw object, so the param is dropped. OpenAPI defines no style/explode encoding for arrays of objects, so — unlike plain object params — this case is unaffected by the object-serialization behavior above.
parameters:
- name: filters
in: query
schema:
type: object
additionalProperties:
type: stringWith the spec above and no paramsSerializer configured, the generated
client now serializes filters per the OpenAPI default (style: form,
explode: true) — spreading its properties as top-level query params. With a
configured paramsSerializer (e.g. qs.stringify), the generated client
instead preserves filters as a raw object and forwards it to the serializer.
If you need the raw object even without a serializer — for example a
mutator that flattens it into bracketed keys — use override.paramsFilter
(see below). See
issue #3326 and
issue #3705 for the
motivating bugs.
Object query parameters (style & explode)
Added in the fix for issue #3705.
OpenAPI's default for an in: query parameter is style: form +
explode: true. For a type: object schema that means the object's
properties are spread as separate top-level query params — not nested under
the parameter's own name. Orval's generated Angular client now honors this
(and the form + explode: false and deepObject variants) at request time,
as long as no paramsSerializer/paramsFilter is configured for the
operation.
paths:
/catalog-items:
get:
operationId: searchCatalog
parameters:
- name: arg0
in: query
schema:
type: object
properties:
itemReferences:
type: array
items:
type: string
pageNumber:
type: integerexport type SearchCatalogParams = {
arg0?: {
itemReferences?: string[];
pageNumber?: number;
};
};searchCatalog({ arg0: { itemReferences: ['a', 'b'], pageNumber: 1 } });
// -> GET /catalog-items?itemReferences=a&itemReferences=b&pageNumber=1The generated parameter type intentionally keeps the shape your spec
declares — arg0 stays nested, mirroring the parameter name reported by your
tooling. Orval does not invent a flatter name for it, since that name is
usually an artifact of the source toolchain rather than something meaningful
in your API. If arg0 (or similar) is showing up because your Spring/
springdoc-based backend isn't compiled with parameter names preserved, fix it
at the source instead: enable the -parameters javac flag (or annotate the
controller method parameter with @ParameterObject from
springdoc-openapi) so the generated spec reports the real parameter name.
style | explode | Wire format | Strategy |
|---|---|---|---|
form (default) | true (default) | itemReferences=a&itemReferences=b&pageNumber=1 | flatten |
form | false | arg0=itemReferences,a,b,pageNumber,1 | comma |
deepObject | (any) | arg0[itemReferences]=a&arg0[itemReferences]=b&arg0[pageNumber]=1 | deepObject |
Nested non-primitive values inside an exploded/deepObject-serialized object
(an object-in-object, or an array of objects) are dropped using the same
primitive-only rules the top-level filter already applies — OpenAPI does not
define a style/explode encoding for them either, so they never reach the
wire as "[object Object]".
To restore the pre-#3705 behavior (object-typed query params silently dropped), set:
export default defineConfig({
petstore: {
output: {
client: 'angular',
target: './src/api/petstore.ts',
override: {
angular: {
queryObjectSerialization: 'legacy',
},
},
},
},
});queryObjectSerialization can be set globally, per-tag, or per-operation,
just like retrievalClient/runtimeValidation. See the
configuration reference.
Custom filter with override.paramsFilter
When the schema cannot describe what you need — for example you want to
preserve empty strings, encode arrays differently, or rename keys before
they hit the wire — supply a paramsFilter mutator. It completely
replaces the built-in filterParams for every operation that resolves
to it; you own nullish-stripping and any non-primitive handling.
Even though paramsFilter is Angular-only, it lives at override.paramsFilter
instead of override.angular.paramsFilter so you can keep using the standard
global / per-operation / per-tag override pattern that Orval already uses for
request shaping.
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
output: {
client: 'angular',
target: './src/api/petstore.ts',
override: {
paramsFilter: {
path: './src/api/params-filter.ts',
name: 'filterPetstoreParams',
},
},
},
},
});// You are now the filter. orval will not strip null, undefined, or object
// values for you — return exactly the shape Angular's HttpParams should see.
export const filterPetstoreParams = (
params: Record<string, unknown>,
): Record<string, unknown> => {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
result[key] = value;
}
return result;
};What you take over when you opt in
The built-in helper is not called alongside your filter. In particular, you are responsible for:
- stripping
undefined(Angular'sHttpParamsdoes not safely represent it for query encoding; handle it explicitly) - handling
null(Angular treats it as the literal string"null") - filtering arrays (
new HttpParams({ fromObject: { tags: [{}] } })is invalid) - serializing object-valued params per their
style/explode(or coercing/ flattening them yourself) — or relying on a downstreamparamsSerializerto do it - the required-nullable empty-string fallback described above — opting into
paramsFilteralso replaces that behavior, so if you prefer the old drop-silently semantics for required-nullable params,paramsFilteris the escape hatch to restore it
The exact rules the default helper applies — useful as a starting point you can adapt:
// This is the logic orval emits when no `paramsFilter` is configured.
// `passthroughKeys` is only non-empty when a `paramsSerializer` is also
// configured (it carries the schema-declared object/array-of-object keys).
// `objectParamStrategies` carries the per-key style/explode strategy
// (issue #3705) and is empty when a `paramsSerializer` is configured or
// `queryObjectSerialization: 'legacy'` is set.
// `preserveRequiredNullables` is true only when a `paramsSerializer` is
// configured — that's what lets the literal `null` survive for the
// serializer to encode; without one, a required-nullable `null` becomes an
// empty string so the key still reaches the wire (see #3712).
// Copy/adapt the parts you want; drop the rest.
type Primitive = string | number | boolean;
type Value = Primitive | Primitive[] | null;
type ObjectParamStrategy = 'flatten' | 'comma' | 'deepObject';
export const defaultFilter = (
params: Record<string, unknown>,
requiredNullableKeys = new Set<string>(),
preserveRequiredNullables = false,
passthroughKeys = new Set<string>(),
objectParamStrategies: Readonly<Record<string, ObjectParamStrategy>> = {},
): Record<string, Value | unknown> => {
const out: Record<string, Value | unknown> = {};
const filterPrimitives = (value: unknown[]) =>
value.filter(
(item): item is Primitive =>
item != null &&
(typeof item === 'string' ||
typeof item === 'number' ||
typeof item === 'boolean'),
);
for (const [key, value] of Object.entries(params)) {
if (passthroughKeys.has(key)) {
if (value !== undefined) out[key] = value;
continue;
}
const strategy = objectParamStrategies[key];
if (strategy && value != null && typeof value === 'object' && !Array.isArray(value)) {
const entries = Object.entries(value as Record<string, unknown>);
if (strategy === 'comma') {
const parts = entries
.filter(([, v]) => v != null && typeof v !== 'object')
.flatMap(([k, v]) => [k, String(v)]);
if (parts.length) out[key] = parts.join(',');
} else {
for (const [prop, propValue] of entries) {
const targetKey = strategy === 'deepObject' ? `${key}[${prop}]` : prop;
if (Array.isArray(propValue)) {
const filtered = filterPrimitives(propValue);
if (filtered.length) out[targetKey] = filtered;
} else if (propValue != null && typeof propValue !== 'object') {
out[targetKey] = propValue as Value;
}
}
}
continue;
}
if (Array.isArray(value)) {
const filtered = filterPrimitives(value);
if (filtered.length) out[key] = filtered;
} else if (value === null && requiredNullableKeys.has(key)) {
out[key] = preserveRequiredNullables ? null : '';
} else if (
value != null &&
(typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean')
) {
out[key] = value;
}
}
return out;
};Composing with paramsSerializer
If you configure both, orval calls them in this order so the serializer operates on the filtered result:
raw params → paramsFilter → paramsSerializer → HttpParamsparamsFilter controls which keys/values reach the request;
paramsSerializer controls how the surviving values are encoded into the
URL. Both are optional and independent. Configuring a paramsSerializer
also disables the built-in object-serialization strategies described in
Object query parameters above —
the raw object is passed through to your serializer instead.
Advanced helpers
Generated httpResource files also include small convenience helpers such as
ResourceState<T> and toResourceState() for integrating Angular resources
into your own state abstractions.
Full Example
See the complete Angular example on GitHub.