Skip to content

Angular

seampact writes an Angular service per api over the fetch client. The client sends through your app’s HttpClient, so your interceptors, XSRF settings and withFetch() apply. Reads are signals built on Angular’s resource(). There is no query library. Tested with Angular 22. The types come from TypeScript types.

targets:
- id: types
use: "@seampact/codegen-ts"
apis: [notes]
- id: client
use: "@seampact/codegen-fetch"
apis: [notes]
link: { types: types }
- id: queries
use: "@seampact/codegen-angular"
apis: [notes]
options: { query: fetch }
link: { client: client }

query: fetch is the only value for now. Then run npx seampact generate.

main.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { provideHttpClient, withInterceptors } from "@angular/common/http";
import { provideNotesApi } from "./__generated__/queries/notes/index.js";
import { App } from "./app/app";
import { authInterceptor } from "./app/auth.interceptor";
import { environment } from "./environments/environment";
bootstrapApplication(App, {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideNotesApi({ baseUrl: environment.apiUrl }),
],
});

App, authInterceptor and environment are yours: your root component, an HttpInterceptorFn that adds the token, and your environment file. apiUrl holds your backend URL: see Point the client at your backend. Note that environment.apiUrl is fixed at build time; for a value chosen at container start, read /config.json in provideAppInitializer.

Two security settings:

  • XSRF: keep the api on the page’s origin and call withXsrfConfiguration({ headerName }) with the CSRF header your contract declares.
  • Cookies on a cross-origin api: set withCredentials in an interceptor. The client config has no fetch option here.
import { Component, inject, input } from "@angular/core";
import { NotesApi } from "./__generated__/queries/notes/index.js";
@Component({ selector: "app-note-list", template: "..." })
export class NoteList {
readonly folderId = input.required<string>();
readonly notes = inject(NotesApi).listNotes(() => ({ query: { folderId: this.folderId() } }));
readonly create = inject(NotesApi).createNote({ retry: 1 });
async add() {
const { error } = await this.create.mutate({ body: { title: "New note" } });
if (!error) this.notes.refetch();
}
}
  • A read returns { data, error, isLoading, refetch }, each a signal but refetch.
  • The request is a function: when a signal it reads changes, the read starts again. Return null to skip a read.
  • Create readers in a field initializer. Elsewhere, pass { injector }.
  • No read runs during server rendering. Pass { transferId: "notes" } to read on the server and hand the data to the browser. Angular’s server transfer caps a body at 1 MiB, so a larger read fails there.

The other behaviours (cache, mutate, retry, paging, refused options) are shared with React and Vue. On Angular, a read stops when the component or the injector is destroyed.

A declared error is thrown as a ContractError: isContractError(error) tells it apart from a network failure (fields and errorStyle: Fetch client). inject(NotesApi).client is the client itself, and createNotesQueries(client) gives the plain fetchers, for code outside Angular.

The service only reads and writes. Keep shared state in your own store or signals.

Add state: ngrx to the options, and install @ngrx/store and @ngrx/effects 22:

options: { query: fetch, state: ngrx }

seampact then writes typed actions and effects. Your reducers, selectors and state stay yours.

bootstrapApplication(App, {
providers: [
provideHttpClient(),
provideStore({ notes: notesReducer }),
provideNotesApi({ baseUrl: environment.apiUrl }),
provideNotesEffects(),
],
});
store.dispatch(NotesActions.listNotes({ request: { query: { folderId } } }));
export const notesReducer = createReducer(
initialNotes,
on(NotesActions.listNotesSuccess, (state, { data }) => ({ ...state, items: data.body.items })),
on(NotesActions.listNotesFailure, (state, { error }) => ({ ...state, error: error.message })),
);
  • One action group per resource of the contract: NotesActions for notes. Operations with no resource are in <Api>ApiActions.
  • Each operation has three actions: listNotes with the request, listNotesSuccess with the request and data, listNotesFailure with the request and the typed error.
  • An effect sends each action through the service. Every error ends in the failure action, a ContractError for a declared error. The effect keeps running.
  • A new read of the same request aborts the one still running, which ends in its failure action with an AbortError. Reads of different requests run side by side.
  • Writes run side by side. Each write action sends its own idempotency key. To retry a write safely, give the key yourself and dispatch again with the same key: call: { idempotencyKey }. The server then runs it once.
  • Effects run wherever the app dispatches, the server included. Dispatch reads in the browser (for example in afterNextRender) to keep them off the server.
  • An operation that needs a timeout takes call: { timeoutMs } in its action.
  • state: ngrx needs the service artefact.
  • A failure action carries the contract error object, so leave NgRx’s strictActionSerializability check off (its default).

Add state: ngrx-signals to the options, and install @ngrx/signals 22:

options: { query: fetch, state: ngrx-signals }

seampact then writes one Signal Store feature per operation. Put the ones you need in your own store:

export const NotesStore = signalStore(
withState({ folderId: "" }),
withListNotes(),
withCreateNote(),
);
bootstrapApplication(App, {
providers: [provideHttpClient(), provideNotesApi({ baseUrl: environment.apiUrl }), NotesStore],
});
export class NotesPage {
readonly store = inject(NotesStore);
readonly notes = computed(() => this.store.listNotes().data?.body.items ?? []);
constructor() {
void this.store.sendListNotes({ query: { folderId: "inbox" } });
}
}
  • Each feature adds a state slice named after the operation (listNotes) and a method send<Operation> (sendListNotes).
  • The slice holds the latest call: status (idle, pending, success or error), its request, and its data or typed error, a ContractError for a declared error.
  • send<Operation> never throws. It resolves to the slice its call ended with.
  • A new read of the same request aborts the one still running, which resolves with an AbortError. Reads of different requests run side by side; the slice shows the last one you sent.
  • Reads do nothing on the server, and resolve to the idle slice. To read there and hand the data to the browser, use withListNotes({ transferId: "notes" }): the browser’s first read of the same request takes that data and sends nothing.
  • Writes run side by side. Each one sends its own idempotency key. To retry a write safely, give the key yourself and send again with the same key: sendCreateNote(request, { idempotencyKey }). The server then runs it once.
  • When the store is destroyed, reads still running are aborted, and the slices no longer change.
  • Your own store members must not reuse a slice or send<Operation> name. Features of two apis in one store clash when their operations share a name. NgRx only warns in dev mode, and the last one wins.
  • An operation that needs a timeout takes { timeoutMs } as its second argument.
  • state: ngrx-signals needs the service artefact.
Option Values Default
query fetch required
errors throw, data (the whole reply as data) throw
state ngrx (actions and effects), ngrx-signals (Signal Store features) none

artefacts: { service: false } keeps the plain fetchers only.

generate refuses staleTime, invalidate, infinite and keys.root (no query library) and a client with errorStyle: throw (link.client.errorStyle.unsupported). See Link errors and the client table.

Point the client at your backend, then MSW mocks to test without a backend.