skip to content

In Angular, how would you design a typed configuration-object token for an API client so that defaults apply and deployments override only what they need?

level: seniorimportance: should knowfreq 32%

answer

  1. one cohesive object, one token
  2. factory returns the defaults
  3. merge partial overrides once
  4. readonly and serialisable

basics

~20 s

Declare one InjectionToken for a readonly config interface, give it a factory that returns complete defaults, and expose a small provider function that merges a partial override with those defaults into a full object, so consumers always inject a complete, typed configuration.

solid answer

~40 s

Group settings that change together — base URL, timeout, retry count — into one `ApiClientConfig` interface and one `InjectionToken<Readonly<ApiClientConfig>>`, instead of a token per primitive. Give the token a factory returning complete defaults, so the client works with zero setup and consumers never see a half-filled object. For overrides, export a function such as `provideApiClientConfig(partial)` that returns `{ provide: API_CLIENT_CONFIG, useValue: { ...DEFAULTS, ...partial } }`; the caller writes only what differs, and the merge happens once, not in every consumer. Keep the object readonly and plain data — no services or callbacks — so it is easy to log, test and override. Consumers inject the whole object and read the fields they need.

code

ts · 41 lines
ts
import { Injectable, InjectionToken, Provider, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { retry, timeout } from 'rxjs';

export interface ApiClientConfig {
  baseUrl: string;
  timeoutMs: number;
  retries: number;
}

const API_CLIENT_DEFAULTS: Readonly<ApiClientConfig> = Object.freeze({
  baseUrl: '/api',
  timeoutMs: 10_000,
  retries: 2,
});

export const API_CLIENT_CONFIG = new InjectionToken<Readonly<ApiClientConfig>>('API_CLIENT_CONFIG', {
  factory: () => API_CLIENT_DEFAULTS,
});

export function provideApiClientConfig(overrides: Partial<ApiClientConfig>): Provider {
  const merged = { ...API_CLIENT_DEFAULTS, ...overrides };
  if (merged.timeoutMs <= 0) {
    throw new Error('ApiClientConfig.timeoutMs must be positive');
  }
  return { provide: API_CLIENT_CONFIG, useValue: Object.freeze(merged) };
}

@Injectable({ providedIn: 'root' })
export class ApiClient {
  private readonly http = inject(HttpClient);
  private readonly config = inject(API_CLIENT_CONFIG);

  get<T>(path: string) {
    return this.http
      .get<T>(`${this.config.baseUrl}${path}`)
      .pipe(timeout(this.config.timeoutMs), retry(this.config.retries));
  }
}

// app.config.ts: providers: [provideHttpClient(), provideApiClientConfig({ baseUrl: 'https://staging.example.test/api' })]

go deeper

for a junior

Know that a configuration object can be provided under one InjectionToken and injected whole, with fields read as needed.

for a middle

Explain complete defaults from a token factory, overrides through a helper that merges a Partial, and why the value is shared by reference.

for a senior

Design the shape: cohesive fields, readonly and frozen, validated once, no behaviour inside, and know when a separate token is the better seam.

for a principal

Treat configuration tokens as a versioned contract between platform and feature teams, with defaults that make most deployments zero-configuration.

## The design problem An API client typically needs several settings: a base URL, a request timeout, a retry count, perhaps a header name. You could create one `InjectionToken` per setting, or one token for an object holding them all. And you want three properties at once: - the client works with **no configuration** in the common case, - a deployment overrides **only the fields that differ**, - every consumer receives a **complete, typed** object, never a partially filled one. ## One token for a cohesive object Settings that are read together and change together belong in one interface: ```ts export interface ApiClientConfig { baseUrl: string; timeoutMs: number; retries: number; } export const API_CLIENT_CONFIG = new InjectionToken<Readonly<ApiClientConfig>>('API_CLIENT_CONFIG', { factory: () => API_CLIENT_DEFAULTS, }); ``` | Approach | Strengths | Weaknesses | |---|---|---| | One token per primitive (`API_BASE_URL`, `API_TIMEOUT`, …) | Each setting overridable on its own; consumers depend only on what they read | Many tokens to declare, document and provide; related settings drift apart | | One configuration-object token | One provider, one place to read the whole configuration, easy to extend | Consumers depend on the whole shape; overriding one field means providing a complete object | The second approach wins for a cohesive client configuration. A single standalone value that many unrelated features read — such as a base URL shared by several clients — can still deserve its own token. ## Defaults through the token's factory Giving the token a **factory** makes it self-providing: with no explicit provider, the root injector creates the default on first request. The factory takes no arguments; if a default depends on another service (for example the document's origin), it calls `inject()`. ## Partial overrides through a provider function Overriding with a raw `useValue` forces callers to restate every field. Instead, export a small helper that accepts a `Partial<ApiClientConfig>` and returns a provider: ```ts export function provideApiClientConfig(overrides: Partial<ApiClientConfig>) { return { provide: API_CLIENT_CONFIG, useValue: Object.freeze({ ...API_CLIENT_DEFAULTS, ...overrides }) }; } ``` Callers write `provideApiClientConfig({ baseUrl: 'https://staging.example.test/api' })` in their application or route providers. The merge happens **once**, where the provider is created, so consumers never repeat `config.retries ?? 3` fallbacks. Two notes on the merge: 1. A spread is shallow. Nested objects need their own merge, or a flatter shape. 2. The helper merges with the static defaults. If a feature area should inherit a parent area's *overrides* and change one more field, the provider must read the parent's value from DI instead — a lookup-modifier concern, and a sign the configuration may be too dynamic for a single object. ## Where overrides go The provider helper can be used at any level that accepts providers, and the level decides who sees the override: - in the application's bootstrap providers, for the whole app; - in a lazy route's `providers`, for code whose injector is that route's injector; - in a component's `providers`, only in rare cases where one widget talks to a different backend. Each level receives a **complete** object from the helper, so an override at a route level never has half the fields of the application-level one. Keep the number of levels small: configuration that changes in many places is harder to reason about than two or three well-known override points. ## Keep the object plain and immutable - **Readonly type and frozen value.** `Readonly<ApiClientConfig>` stops accidental writes at compile time; `Object.freeze` catches them at runtime. The object is shared by every consumer of the injector, so a mutation would leak everywhere. - **Data only.** Put services, callbacks and observables behind their own tokens. Plain data is easy to log, snapshot in tests and override. - **Validate early.** The helper is the natural place to assert that `timeoutMs` is positive or `baseUrl` has no trailing slash, failing at startup rather than on the first request. ## Consuming it Consumers inject the object and read fields: ```ts private readonly config = inject(API_CLIENT_CONFIG); ``` Because the object is complete and typed, there are no optional-chaining fallbacks scattered through the client. Tests either rely on the defaults or add one `provideApiClientConfig({...})` entry. ## Checklist - One interface for settings that change together; separate tokens for unrelated values. - Token factory supplies complete defaults. - One exported helper merges partial overrides into a complete, frozen object. - No behaviour inside the configuration object.

  • A developer mutates config.retries inside one service to handle a flaky endpoint. Why is that a bug?
    Every consumer of that injector shares the same configuration object, so the change silently alters retries for every other request path, and only after this service happened to run. Freezing the object turns the mutation into an immediate error; the right fix is a per-call option or a separate override provided where that feature lives.
  • When would you split the configuration object into several tokens after all?
    When unrelated features read unrelated fields, or when one field is overridden at a different level of the injector tree than the rest. Splitting keeps each consumer's dependency narrow and avoids re-providing a whole object to change one value in a subtree.

saying these in an interview costs you the question

  • Every setting needs its own InjectionToken to be overridable
  • Consumers should merge defaults with ?? fallbacks at each use
  • Putting callbacks and services inside the config object keeps DI simpler
  • A spread merge deep-merges nested configuration objects
  • The provided configuration object is copied for each consumer