Home / Articles

An Immutable, Closure-Based Query Builder for Angular HttpClient

Replace repeated HttpParams boilerplate with a small closure-based request builder, wired through an InjectionToken, that stays immutable, typed and easy to test.

Publisher: REACTAPP.TOP9 min read2004 words

This article is published in English.

Sources: Stop Hand-Wiring HttpParams: Build a Fluent, Type-Safe Request Builder in Angular

Large Angular codebases tend to accumulate the same few lines in dozens of services: create an HttpParams, set the filter, set the sort, set the page, then finally make the request. Each copy is slightly different, and those small differences become inconsistent query parameter names and sorting conventions across teams.

This guide shows how to replace that repetition with a fluent request builder built only from closures. It needs neither classes nor decorators nor inheritance; a single factory function is exposed through an InjectionToken and resolved with inject(). You will see how it works, why it is immutable by construction, how to test it without TestBed, and when it is not worth adding.

The boilerplate being replaced

Here is a typical list endpoint written by hand.

getUsers(status: string, sortField: string, page: number, size: number) {
  let params = new HttpParams()
    .set('status', status)
    .set('sort', sortField)
    .set('direction', 'asc')
    .set('page', page)
    .set('size', size);

  return this.http.get<User[]>('/api/users', { params });
}

Nothing about it is wrong, but it does not scale well. One service names the page size pageSize, another uses size; one team sorts with sort plus direction, another encodes both in a single parameter. Every new endpoint brings another block of HttpParams calls before the part that actually matters. The goal is a call site that reads like a description of the query:

loadUsers() {
  return this.request<User[]>('/api/users')
    .where('status', 'active')
    .sortBy('name')
    .paginate(0, 20)
    .execute();
}

Each call in that chain returns a new, independent value, and the final execute() produces the observable. The rest of this guide builds the pieces that make it possible.

A factory that returns itself

The whole builder is about twenty lines.

import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';

type SortDirection = 'asc' | 'desc';

interface RequestBuilder<T> {
  where(key: string, value: string | number | boolean): RequestBuilder<T>;
  sortBy(field: string, direction?: SortDirection): RequestBuilder<T>;
  paginate(page: number, size: number): RequestBuilder<T>;
  execute(): Observable<T>;
}

export function createRequest(http: HttpClient) {
  const build = <T>(url: string, params = new HttpParams()): RequestBuilder<T> => ({
    where: (key, value) => build<T>(url, params.set(key, String(value))),

    sortBy: (field, direction = 'asc') =>
      build<T>(url, params.set('sort', field).set('direction', direction)),

    paginate: (page, size) =>
      build<T>(url, params.set('page', page).set('size', size)),

    execute: () => http.get<T>(url, { params }),
  });

  return build;
}

The central trick: calling build gives you an object, and each method on that object calls build once more. Every step in a chain creates a fresh builder that closes over the updated HttpParams. The previous builder is never changed.

This works so neatly because HttpParams is already immutable: params.set() leaves the original instance alone and returns a new one with the extra entry. The builder simply lifts that property up one level, so the builder objects inherit the same guarantee. where converts values to strings, while sortBy writes the sort and direction keys (defaulting to ascending) and paginate writes page and size. Only execute() touches the network, by calling http.get with the accumulated parameters.

It is also worth noticing what is missing. There is no this, no private field and no class. All state lives in function arguments captured by closures, which leaves nothing to mutate by accident and nothing to track outside the call in front of you. If closures that capture arguments feel unfamiliar, the explanation of how JavaScript's scope chain resolves variables covers the underlying mechanism.

Exposing the builder through dependency injection

A function that takes HttpClient is useful, but components should be able to inject it like any other dependency. A factory-based InjectionToken is designed for exactly this.

import { InjectionToken, inject } from '@angular/core';

export const REQUEST_BUILDER = new InjectionToken<ReturnType<typeof createRequest>>(
  'REQUEST_BUILDER',
  {
    providedIn: 'root',
    factory: () => createRequest(inject(HttpClient)),
  }
);

Two details deserve attention.

Let the compiler derive the type

ReturnType<typeof createRequest> means the builder's type is never written out by hand. When createRequest gains a method, the token's type follows automatically, and every consumer sees the change.

Resolve HttpClient where an injection context exists

inject(HttpClient) runs inside the token's factory, and that factory always executes in an injection context. That is the right place for it. A frequent mistake is calling inject() from an ordinary method or a callback that runs outside any injection context; Angular then throws NG0203 at runtime. Resolving HttpClient once, when the token is created, and capturing it in the closure means the resulting builder can be used anywhere afterwards: inside effects, RxJS operators or plain utility functions.

Using it from a component

A component injects the token into a field and calls it like a function.

@Component({
  selector: 'app-users',
  template: `...`,
})
export class UsersComponent {
  private readonly request = inject(REQUEST_BUILDER);

  loadUsers() {
    return this.request<User[]>('/api/users')
      .where('status', 'active')
      .sortBy('name')
      .paginate(0, 20)
      .execute();
  }
}

The generic argument supplied at the start, User[], travels through the entire chain, so execute() returns Observable<User[]> without any casting.

Patterns that fall out of immutability

Reactive search with signals

Because a builder is a cheap, immutable value, it is perfectly fine to create a new one every time reactive inputs change. Here a signal-driven resource rebuilds the query from the current search text and page.

export class ProductSearchComponent {
  private readonly request = inject(REQUEST_BUILDER);

  readonly query = signal('');
  readonly page = signal(0);

  readonly products = rxResource({
    params: () => ({ q: this.query(), page: this.page() }),
    stream: ({ params }) =>
      this.request<Product[]>('/api/products')
        .where('q', params.q)
        .where('inStock', true)
        .sortBy('price', 'desc')
        .paginate(params.page, 24)
        .execute(),
  });
}

Whenever query or page changes, the params function produces a new object, and the stream function builds a new request from it. There is no shared builder to reset between runs. Note that rxResource and its option names have changed across recent Angular releases (earlier versions used different property names), so check the API of the version you are on.

Conditional chaining

Since every step returns a value rather than modifying one, optional filters can be applied by reassigning a local variable.

loadOrders(filter: OrderFilter) {
  let builder = this.request<Order[]>('/api/orders')
    .paginate(filter.page, filter.size);

  if (filter.status) {
    builder = builder.where('status', filter.status);
  }
  if (filter.sortField) {
    builder = builder.sortBy(filter.sortField, filter.direction);
  }

  return builder.execute();
}

Only the builder variable is reassigned; the builder objects themselves never change. Each branch simply swaps in a new, extended builder.

Testing without TestBed

createRequest is a plain function of HttpClient, so a unit test needs no Angular testing module at all. A small stub that records the parameters it receives is enough.

it('accumulates params immutably', () => {
  const captured: HttpParams[] = [];
  const httpStub = {
    get: (_url: string, opts: { params: HttpParams }) => {
      captured.push(opts.params);
      return of([]);
    },
  } as unknown as HttpClient;

  const request = createRequest(httpStub);
  const base = request<unknown[]>('/api/items').where('a', 1);

  base.where('b', 2).execute();
  base.where('c', 3).execute();

  expect(captured[0].get('b')).toBe('2');
  expect(captured[0].get('c')).toBeNull(); // branches don't leak into each other
  expect(captured[1].get('c')).toBe('3');
});

The final assertions show the benefit of immutability. Chains that branch off one shared base builder remain fully isolated: the b parameter from the first chain never appears in the second, and vice versa. With a mutable builder, the second call would silently inherit the first one's filter.

Why the design holds up

  • Immutable by construction. No internal state exists that could be corrupted. Reusing a partially built request, for instance one base query for active users and another for archived users, is safe by default. A mutable class-based builder gets this wrong without any warning.
  • One vocabulary for query parameters. The sort, direction, page and size keys are defined in a single place. When the backend renames one, you change one line rather than searching through forty services. The builder effectively becomes the contract between the frontend and the API's query conventions.
  • Inference that reaches the end of the chain. A single generic at the entry point types every step, including execute(). Class-based builders often force generics to be passed again through each layer of inheritance.
  • Dependency resolution happens once, in the right place. The token factory resolves HttpClient inside a guaranteed injection context, avoiding the whole family of "inject() called outside an injection context" errors that appear when builders are created lazily in callbacks.
  • Tree-shakable and easy to test. A factory token with providedIn: 'root' is dropped from the bundle when nothing uses it. The core is a plain function, so tests need neither TestBed.configureTestingModule nor HttpTestingController, unless you specifically want integration-level coverage.
  • Interceptors keep working. execute() ends up calling HttpClient.get, so authentication headers, retry logic and error mapping in your interceptor chain apply unchanged. The builder sits on top of HttpClient; it does not replace it.

Trade-offs to weigh

  • Keys are plain strings. where('status', 'active') is not validated against the API. A typo in the key compiles and only fails at runtime. Constraining keys with a generic, such as where<K extends keyof UserQuery>, fixes that, at the cost of more complex types; decide whether your team needs it.
  • Repeated calls overwrite. HttpParams.set replaces an existing value, so calling sortBy() twice keeps only the second call. That is usually what you want for sorting, but it surprises anyone expecting multi-value parameters. For repeated keys such as ?tag=a&tag=b, add a whereAll method that uses params.append.
  • Deliberately GET-only. The builder composes query strings. Request bodies, headers and response-type options are out of scope. You can add methods like withHeaders or post(body), but each addition drifts toward reimplementing the options object HttpClient already has. Know where to stop.
  • A new object per step. Every chained call allocates a builder and an HttpParams. These are tiny, short-lived objects that JavaScript engines collect cheaply, so the cost is negligible in practice, but it is worth knowing.
  • One more abstraction to learn. New team members have to discover that REQUEST_BUILDER exists and what it does. In an app with two endpoints, calling HttpClient directly is simpler. The pattern pays off in codebases with many list and table endpoints that share pagination and sorting rules, which describes a large share of enterprise Angular applications.

Ways to extend it

Once the basic version is settled, a few extensions are natural.

Typed query schemas per endpoint

A second generic can describe which keys and values an endpoint accepts, turning a misspelled key into a compile-time error.

interface UserQuery {
  status: 'active' | 'archived';
  role: string;
}

const users = this.request<User[], UserQuery>('/api/users')
  .where('status', 'active'); // 'staus' would now be a compile error

This snippet shows the intended call site. To support it, createRequest and the RequestBuilder interface need a second type parameter, and where must be typed in terms of keyof that query type and its value types.

Environment-specific base URLs

Resolve a BASE_API_URL token in the token factory as well, and prepend it to each path the builder receives. Components keep passing relative paths and never deal with environment configuration.

Response adapters

A map stage can reshape the server response inside the builder, which means components get view-ready models instead of raw API shapes.

Wrapping up

The closure-based request builder offers a lot for very little code. About twenty lines produce an immutable, typed, injectable layer that standardises how the whole application talks to paginated endpoints. It relies only on what Angular already provides: the immutability of HttpParams, factory-based injection tokens and inject(), with no extra runtime machinery.

As with any abstraction, there is a break-even point. For a handful of endpoints, plain HttpClient calls are clearer. Beyond that, the builder turns query-string conventions from knowledge people have to remember into a contract the compiler checks for you.