skip to content

In Angular, why can't a TypeScript interface serve as a DI token, and how does an InjectionToken solve that for an API_BASE_URL string?

level: juniorimportance: must knowfreq 62%

answer

  1. types vanish after compilation
  2. a token must exist at runtime
  3. identity by object reference
  4. generic parameter types inject()
  5. description is for debugging only

basics

~20 s

TypeScript interfaces are erased at compile time, so there is nothing at runtime for the injector to use as a key. An InjectionToken<T> is a real object that serves as that key, and its type parameter makes inject() return T.

solid answer

~40 s

Angular's injector looks providers up by a runtime value, and interfaces and type aliases do not survive compilation, so `{ provide: ApiSettings, … }` with an interface cannot even compile as a value. Primitive values have the same problem: a string such as a base URL has no class to act as its key. `new InjectionToken<string>('API_BASE_URL')` creates a unique object to use instead. You register it once — `{ provide: API_BASE_URL, useValue: 'https://api.example.test' }` — and `inject(API_BASE_URL)` returns a value typed as `string`, because `inject()` reads `T` from the token. The string argument is only a description for error messages; the injector matches tokens by object identity, so the token must be exported once and imported everywhere, not re-created in each file.

code

ts · 28 lines
ts
import { ApplicationConfig, Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient, provideHttpClient } from '@angular/common/http';

// tokens.ts - declared once, imported everywhere
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

export interface Order {
  id: string;
  totalCents: number;
}

@Injectable({ providedIn: 'root' })
export class OrdersApi {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = inject(API_BASE_URL); // typed as string

  list() {
    return this.http.get<Order[]>(`${this.baseUrl}/orders`);
  }
}

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    { provide: API_BASE_URL, useValue: 'https://api.example.test' },
  ],
};

go deeper

for a junior

Recall that interfaces disappear after compilation, so non-class dependencies need an InjectionToken, and that inject() returns the token's T.

for a middle

Explain identity-based matching, what the description is for, and why a token must be declared once and imported by both sides.

for a senior

Diagnose the duplicated-token failure, including duplicates coming from two copies of a library, and decide when a class key is clearer than a token.

for a principal

Set conventions for where shared tokens live and who owns them, so configuration keys do not multiply or drift across teams and libraries.

## Why a token has to be a runtime value Angular's **injector** is, at its core, a map from **tokens** to providers. When code calls `inject(SomeToken)`, the injector looks up `SomeToken` in its records, walking up the injector tree until it finds a provider. That lookup is done in JavaScript, at runtime, so the token has to be a JavaScript value. A class works as a token because a class *is* a runtime value — a constructor function. A TypeScript `interface` or `type` alias does not: it exists only for the type checker and is **erased** during compilation. Writing `inject(ApiSettings)` where `ApiSettings` is an interface fails to compile, because there is no value named `ApiSettings` to pass. The same gap appears for anything that is not a class instance: - a primitive such as a base URL string or a retry count, - a plain configuration object described by an interface, - a function, such as a `now(): number` clock, - an array, such as a list of contributions. ## What `InjectionToken` provides `InjectionToken<T>` from `@angular/core` fills that gap. Each `new InjectionToken<T>(description)` call creates a distinct object that can be used as a key: ```ts export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL'); ``` It gives you three things: 1. **A unique runtime key.** The injector compares tokens by **object reference**. Two tokens created with the same description are still two different tokens. 2. **Type information.** The generic parameter `T` is carried in the token's type, and `inject()` is declared to return `T` for an `InjectionToken<T>`. `inject(API_BASE_URL)` is therefore typed as `string` with no cast. 3. **A readable name in errors.** The description is used when Angular prints the token, for example in a no-provider error, so `API_BASE_URL` shows up in the message instead of an anonymous object. ## Providing and consuming the token The token is only the key; a provider supplies the value. For a base URL that differs per deployment, a `useValue` provider at application level is typical, and every service that needs it injects the token: ```ts { provide: API_BASE_URL, useValue: 'https://api.example.test' } private readonly baseUrl = inject(API_BASE_URL); // string ``` Consumers do not know or care where the URL came from — a build-time environment file, a value read before bootstrap, or a test. ## The identity trap | Situation | Result | |---|---| | One exported `API_BASE_URL`, imported by provider and consumers | Works | | Provider and consumer each call `new InjectionToken('API_BASE_URL')` | Different tokens: the consumer gets a no-provider error (NG0201) | | Same description reused by two libraries for different tokens | No clash: identity, not the description, decides | Because identity is what matters, define each token **once**, in a small module that both the providing code and the consuming code import. Re-declaring a token in a second file "with the same name" is a classic bug: everything compiles, and the lookup fails at runtime. ## Tokens in libraries and shared packages The identity rule has a second, less obvious consequence. A library that exports a token must be loaded **once**. If an application ends up with two copies of the same library — two versions resolved side by side, or a package bundled into another package — each copy evaluates its own `new InjectionToken(...)` call and produces its own token object. The application may provide the token from one copy while a component from the other copy injects it, and the lookup fails even though every file looks correct. When a no-provider error names a token you can see being provided, check for duplicate copies of the package before anything else. Libraries should also export their tokens from the public entry point, so consumers never need to re-declare one. ## When a class is still the better key If the dependency is naturally a class with behaviour, use the class (or an abstract class) as the token and skip `InjectionToken` entirely. `InjectionToken` is for values that have no class of their own. Choosing between class, abstract-class and `InjectionToken` keys is a design decision worth making deliberately rather than by habit. ## Summary - Interfaces and type aliases cannot be tokens because they do not exist at runtime. - `InjectionToken<T>` is a runtime key plus a compile-time type. - The description string helps debugging but plays no part in matching. - Export each token once and import it everywhere.

  • Your service throws a no-provider error for InjectionToken API_BASE_URL, yet app.config.ts clearly provides API_BASE_URL. What do you check first?
    Whether both files import the same token object. A second `new InjectionToken('API_BASE_URL')` in another file — often a copy-pasted declaration or a duplicated library build — is a different key with the same description, so the provider and the consumer never meet. Consolidate to one exported declaration.
  • Does the type parameter of InjectionToken check the value you provide at runtime?
    No. `T` exists only for the type checker. `inject()` is typed to return `T`, and a typed provider helper can constrain what you pass, but a plain `{ provide: API_BASE_URL, useValue: 42 }` object literal is not checked against `T`, and Angular does not validate the value at runtime.

saying these in an interview costs you the question

  • An interface can be used as a token if it is exported
  • Two InjectionTokens with the same description are the same token
  • The description string is the key the injector looks up
  • Angular checks at runtime that the provided value matches the token's T
  • InjectionToken is only needed for NgModule-based apps