OrvalOrval

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 httpClient for classic Angular service-based APIs
  • use httpResource for signal-first read flows
  • use both when you want resource-based reads and service-based writes together
  • enable override.angular.runtimeValidation when you generate Zod schemas and want runtime response validation

Configuration

Set output.client to angular to enable Orval's Angular generator:

orval.config.ts
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 by HttpClient
  • httpResource — generate signal-first httpResource functions for retrieval-style operations
  • both — generate httpResource retrievals and keep HttpClient methods 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):

app.config.ts
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:

orval.config.ts
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.retrievalClientWhat Orval generatesChoose it when
httpClientRetrievals stay on injectable service classes backed by HttpClientYou want conventional Angular services, imperative request methods, and the broadest compatibility with request options and mutators
httpResourceRetrieval-style operations become signal-first httpResource functions; mutations still use HttpClient service methodsYou want Angular-native signal ergonomics for reads and prefer resource-based data fetching
bothService classes plus sibling *.resource.ts retrieval helpersYou want signal-first reads and imperative writes together

Quick rule of thumb:

  • choose httpClient for classic Angular service-based APIs
  • choose httpResource for signal-first read flows
  • choose both when your app wants both patterns side-by-side
  • if you do not set override.angular.retrievalClient, Orval defaults to httpClient
  • create, update, delete, and other mutation-style operations still use HttpClient methods 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:

api.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';

export const apiInterceptor: HttpInterceptorFn = (req, next) => {
  return next(
    req.clone({
      url: `https://api.example.com${req.url}`,
    }),
  );
};
app.config.ts
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:

orval.config.ts
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:

petstore.base-url.ts
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 (petstorePETSTORE_* / 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:

  1. A directly provided value via providePetstoreBaseUrl(...) — wins outright, the resolver below is never invoked.
  2. A resolver provided via providePetstoreBaseUrlResolver(...).
  3. The default resolver factory, which just returns the embedded server URL.
  4. The embedded PETSTORE_SERVER_URL constant, resolved at generation time from the specification's servers field (empty string '' when the specification has no servers entry, 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:

app.config.ts
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:

pets.service.spec.ts
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:

pets/pets.resource.ts
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.runtimeValidation keeps 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 to generateMutatorConfig, so a configured mutator sees the same fully composed URL a plain HttpClient call 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.baseUrl bakes a static prefix into every generated route string for all clients; combining it with override.angular.baseUrl would double-prefix (or conflict with) every URL, so Orval throws a config-time error if both are set on the same output. Remove output.baseUrl and use providePetstoreBaseUrlResolver if you need the equivalent of a runtime-configurable prefix.
  • apiId is always explicit. It's never derived from the specification's info.title or 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 servers field is embedded as the fallback URL. If your specification sets servers per path/operation, the token still falls back to the spec-level servers entry (selected via index/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+:

orval.config.ts
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>:

pets.service.ts
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.

  • GET operations are generated as resources
  • retrieval-style POST operations, such as search/list/find/query endpoints, can also be generated as resources
  • mutation-style operations remain HttpClient service methods

If you need to override the default classification for a specific operation, use an operation-level Angular override:

orval.config.ts
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.

pets.page.ts
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.

pets.service.ts
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:

orval.config.ts
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.

pets.page.ts
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:

orval.config.ts
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/loading
  • debugName — name shown in Angular DevTools

Advanced options:

  • injector — raw expression passed to HttpResourceOptions.injector
  • equal — raw expression passed to HttpResourceOptions.equal

For more advanced cases, you can also pass raw expressions for Angular's resource options:

orval.config.ts
override: {
  angular: {
    retrievalClient: 'httpResource',
    httpResource: {
      injector: 'inject(Injector)',
      equal: '(a, b) => a?.id === b?.id',
    },
  },
}

Operation-level httpResource options override global ones:

orval.config.ts
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:

orval.config.ts
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 an HttpHeaders instance / plain record, or a function returning one.
  • context — an Angular HttpContext forwarded to the underlying request. Accepts either an HttpContext instance or a function returning one.
  • request — a last-resort escape hatch that receives the fully-built HttpResourceRequest and 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).

orval.config.ts
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.

pets.page.spec.ts
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

  1. Set schemas.type to zod to generate Zod schemas
  2. Set override.angular.runtimeValidation: true to enable runtime validation
orval.config.ts
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 HttpClient services
  • JSON model responses returned by generated GET methods
  • JSON model responses returned by generated mutation methods such as POST, PUT, PATCH, and DELETE
  • JSON httpResource retrievals 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.runtimeValidation is 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
orval.config.ts
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

ClientConfig keyRuntime validation
angularoverride.angular.runtimeValidation✅ Supported
angular-queryoverride.query.runtimeValidation✅ Supported
fetchoverride.fetch.runtimeValidation✅ Supported
custom mutator pathsvaries⚠️ 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)

  1. Primitive params keep the existing nullish-stripping, with one exception: a param the spec declares both required and nullable. Silently dropping a required param would send a request that violates the OpenAPI contract, so it is never simply removed. Without a paramsSerializer, a null value for such a param is sent as an empty string (?key=) so the key still reaches the wire. With a paramsSerializer configured, the literal null is passed through for the serializer to encode instead. Optional nullable params are still dropped when null — only required-nullable params get this treatment. See issue #3712.
  2. Schema-declared object params are serialized according to the OpenAPI parameter's style/explode (see Object query parameters below) when no paramsSerializer is configured. When a paramsSerializer is configured, the object is instead passed through untouched — that serializer becomes the consumer responsible for turning the raw object into something HttpParams can send.
  3. Arrays of objects are passed through untouched only when a paramsSerializer is configured; without one (or a paramsFilter, 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.
openapi.yaml
parameters:
  - name: filters
    in: query
    schema:
      type: object
      additionalProperties:
        type: string

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

openapi.yaml
paths:
  /catalog-items:
    get:
      operationId: searchCatalog
      parameters:
        - name: arg0
          in: query
          schema:
            type: object
            properties:
              itemReferences:
                type: array
                items:
                  type: string
              pageNumber:
                type: integer
generated SearchCatalogParams (unchanged, still nested)
export type SearchCatalogParams = {
  arg0?: {
    itemReferences?: string[];
    pageNumber?: number;
  };
};
searchCatalog({ arg0: { itemReferences: ['a', 'b'], pageNumber: 1 } });
// -> GET /catalog-items?itemReferences=a&itemReferences=b&pageNumber=1

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

styleexplodeWire formatStrategy
form (default)true (default)itemReferences=a&itemReferences=b&pageNumber=1flatten
formfalsearg0=itemReferences,a,b,pageNumber,1comma
deepObject(any)arg0[itemReferences]=a&arg0[itemReferences]=b&arg0[pageNumber]=1deepObject

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:

orval.config.ts
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.

orval.config.ts
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',
        },
      },
    },
  },
});
src/api/params-filter.ts
// 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's HttpParams does 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 downstream paramsSerializer to do it
  • the required-nullable empty-string fallback described above — opting into paramsFilter also replaces that behavior, so if you prefer the old drop-silently semantics for required-nullable params, paramsFilter is the escape hatch to restore it

The exact rules the default helper applies — useful as a starting point you can adapt:

default-filter-behaviour.ts
// 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 → HttpParams

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

On this page