In Angular's HttpClient, why does calling params.set('units', 'metric') on an HttpParams object leave the request's query string unchanged?
answer
- look at the return value
- clone on every change
- reassign or chain
- headers behave the same way
basics
~20 sHttpParams and HttpHeaders are immutable: set(), append() and delete() return a new instance and leave the original untouched. Discarding the return value discards the change, so reassign the result, chain the calls, or pass a plain object literal instead.
solid answer
~40 s`HttpParams` and `HttpHeaders` in `@angular/common/http` are **immutable**. Their `set()`, `append()` and `delete()` methods do not modify the object; each returns a new instance with the change queued on top of the old one. So `params.set('units', 'metric')` on its own line builds a new object and throws it away. The fix is `params = params.set(...)`, a chain such as `new HttpParams().set('city', city).set('units', 'metric')`, or a plain object in the `params` option, which accepts strings, numbers, booleans and arrays. Immutability exists so that a request, and the params and headers inside it, can be reused, retried and cloned by interceptors without one piece of code changing another's request.
code
ts · 34 linesimport { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Forecast {
city: string;
tempC: number;
}
@Injectable({ providedIn: 'root' })
export class WeatherService {
private readonly http = inject(HttpClient);
getForecast(city: string, days: number): Observable<Forecast[]> {
// Chained: each call returns the new instance (a standalone params.set(...) line would be discarded).
const params = new HttpParams()
.set('city', city)
.set('days', days)
.append('fields', 'temp')
.append('fields', 'wind'); // ?city=...&days=...&fields=temp&fields=wind
const headers = new HttpHeaders({ Accept: 'application/json' }).set('X-Units', 'metric');
return this.http.get<Forecast[]>('/api/forecast', { params, headers });
}
// Equivalent, with plain objects converted by HttpClient itself.
getForecastShort(city: string, days: number): Observable<Forecast[]> {
return this.http.get<Forecast[]>('/api/forecast', {
params: { city, days, fields: ['temp', 'wind'] },
headers: { Accept: 'application/json', 'X-Units': 'metric' },
});
}
}go deeper
Recognise the discarded-return-value bug immediately and show the chained form or the plain-object form of the params and headers options.
Explain the lazy clone-and-queue implementation, the set versus append versus delete semantics, and why arrays in a params object become repeated keys.
Connect immutability to retries, interceptor cloning and shared defaults, and know when a custom HttpParameterCodec is needed for a backend with non-standard encoding.
Decide where request-building conventions live, such as typed query builders in data services versus ad hoc objects at call sites, so encoding and header policy stay consistent across teams.
## The symptom A weather-lookup service needs `?city=Oslo&units=metric`. The developer writes: ```ts const params = new HttpParams(); params.set('city', city); params.set('units', 'metric'); return this.http.get<Forecast>('/api/forecast', { params }); ``` The request goes out as `/api/forecast` with no query string. Nothing throws, and the TypeScript compiler has no complaint, because calling a method and ignoring its result is legal. ## Why: `HttpParams` and `HttpHeaders` are immutable `HttpParams` (query-string parameters) and `HttpHeaders` (request headers) are both **immutable value objects**. The Angular guide states it directly: mutation methods such as `append()` return a new instance with the mutation applied. In the v22 source, `set`, `append`, `appendAll` and `delete` all call a private `clone()` that creates a new object pointing at the original plus a list of queued updates; the updates are applied lazily the first time the new instance is read or serialised. The consequence for the snippet above: each `set()` built a fresh `HttpParams`, and each one was discarded. The `params` variable still holds the empty original. ## The three correct forms 1. **Reassign** each result: `let params = new HttpParams(); params = params.set('city', city);`. 2. **Chain** the calls, since each returns the new instance: `new HttpParams().set('city', city).set('units', 'metric')`. 3. **Pass a plain object** to the `params` option: `{ params: { city, units: 'metric', days: 3 } }`. `HttpClient` converts it with `new HttpParams({ fromObject })`. Values may be strings, numbers, booleans or arrays of them; an array becomes a repeated key (`fields=temp&fields=wind`). The same forms apply to headers: `new HttpHeaders({ Accept: 'application/json' }).set('X-Units', 'metric')`, or a plain object in the `headers` option. Building parameters conditionally is where the reassign form earns its keep: ```ts let params = new HttpParams().set('city', city); if (days !== undefined) { params = params.set('days', days); } if (includeWind) { params = params.append('fields', 'wind'); } ``` Each `if` replaces `params` with the extended copy. `appendAll({ fields: ['temp', 'wind'] })` adds several values in one call, and `new HttpParams({ fromString: 'city=Oslo&days=3' })` parses an existing query string. ## Method semantics worth knowing | Method | Effect on the returned copy | |---|---| | `set(name, value)` | Replaces every existing value for that name with this one | | `append(name, value)` | Adds a value, keeping existing ones (repeated keys or multi-value headers) | | `delete(name)` | Removes the name entirely | | `delete(name, value)` | Removes only that value, keeping the others | | `get(name)` | Returns the first value, or `null` when absent | | `getAll(name)` | Returns every value, or `null` when absent | A few further details: - **`HttpHeaders` lookups are case-insensitive**: `has()` and `get()` lower-case the name, so `get('x-units')` finds a header set as `X-Units`. - **`HttpParams` encoding** goes through `HttpUrlEncodingCodec` by default: it applies `encodeURIComponent` and then restores a few characters (`@`, `:`, `$`, `,`, `;`, `=`, `?`, `/`) to their literal form. A server that needs different encoding gets a custom `HttpParameterCodec` through the `encoder` option. - **`fromString` and `fromObject` are mutually exclusive** in the `HttpParams` constructor; passing both throws. - Values are converted with a template string, so a value that is `undefined` at runtime (possible when it is typed `any`) is sent as the literal text `undefined`. ## Why Angular chose immutability - A request object can be **reused and retried**: re-subscribing to an `HttpClient` Observable sends the same request again, so nothing may have changed it in between. - **Interceptors** receive a request they must not modify in place; they produce a modified copy with `req.clone(...)`. Immutable params and headers make that copy cheap and safe. - A shared base object, such as default headers held in a service, can be extended per call without one call leaking into another. ## How to spot it in review - A line that calls `set`, `append` or `delete` on `HttpParams` or `HttpHeaders` and ignores the result. - A `const` declaration followed by "mutations" of the same variable. - A request that shows up in the network panel without its expected query string or header.
- In Angular's HttpParams, what is the difference between set() and append() for a key that already has a value?`set()` replaces every existing value for that key with the new one, while `append()` adds another value and keeps the old ones, which serialises as a repeated key such as `fields=temp&fields=wind`. Both return a new `HttpParams`; neither touches the original. `delete(name)` removes the key, and `delete(name, value)` removes just that one value.
- A backend expects query values escaped differently from Angular's default. How do you control how HttpParams encodes them?By default `HttpParams` uses `HttpUrlEncodingCodec`, which runs `encodeURIComponent` and then restores a handful of characters such as `@`, `:`, `=`, `?` and `/`. To change that, implement `HttpParameterCodec` (`encodeKey`, `encodeValue`, `decodeKey`, `decodeValue`) and pass it as `new HttpParams({ encoder })`. Check the resulting URL in the network panel rather than assuming.
saying these in an interview costs you the question
- HttpParams.set() modifies the existing object in place, like Map.set().
- Declaring params with const is what stops set() from changing it.
- HttpClient requires an HttpParams instance; plain objects are not accepted for params.
- HttpHeaders.get() is case-sensitive, so the header name must match exactly.
- set() and append() behave identically when the key already exists.