skip to content

In an Angular component library, what is the lightweight injection token pattern, and why does it let an unused component be tree-shaken?

level: seniorimportance: nice to knowfreq 22%

answer

  1. type position vs value position
  2. queries and inject() keep references
  3. small abstract class as the key
  4. component aliases itself to the key

basics

~20 s

A library component that queries or injects another component by class keeps that class in the bundle even when unused. The pattern swaps the class for a small abstract token the optional component provides via useExisting, so only the tiny token is retained.

solid answer

~40 s

Type annotations are erased, but a class passed as a value — `contentChild(LibPaginator)` or `inject(LibPaginator, { optional: true })` — is a runtime reference, so the bundler must keep `LibPaginator` with its template and styles even if no app uses `<lib-paginator>`. The lightweight injection token pattern breaks that reference: declare a small `abstract class LibPaginatorToken` with the abstract members the parent needs, have `LibPaginator` extend it and register `{ provide: LibPaginatorToken, useExisting: LibPaginator }`, and make the table query `LibPaginatorToken` instead. Now the table references only the token. If an app never uses the paginator, nothing references `LibPaginator` and it is dropped; the abstract class stays, but it is a few bytes. The Angular guide aims the pattern at library authors, where consumers cannot fix the retention themselves.

code

ts · 29 lines
ts
import { Component, Signal, computed, contentChild, signal } from '@angular/core';

// Retained in every bundle, but tiny: no template, no styles.
export abstract class LibPaginatorToken {
  abstract readonly pageIndex: Signal<number>;
}

@Component({
  selector: 'lib-paginator',
  providers: [{ provide: LibPaginatorToken, useExisting: LibPaginator }],
  template: `<button (click)="next()">Next page</button>`,
})
export class LibPaginator extends LibPaginatorToken {
  private readonly page = signal(0);
  readonly pageIndex = this.page.asReadonly();
  next() {
    this.page.update((p) => p + 1);
  }
}

@Component({
  selector: 'lib-table',
  template: `<p>Page {{ currentPage() + 1 }}</p><ng-content />`,
})
export class LibTable {
  // References only the token, so LibPaginator can be tree-shaken when unused.
  private readonly paginator = contentChild(LibPaginatorToken);
  protected readonly currentPage = computed(() => this.paginator()?.pageIndex() ?? 0);
}

go deeper

for a junior

Know that a class used as a runtime value is kept in the bundle, while a class used only as a type is erased.

for a middle

Walk through the four parts: abstract token, component extends it, useExisting self-provider, parent queries the token.

for a senior

Recognise the retention in a library's content queries or optional inject() calls and apply the pattern with a typed abstract API and absence handling.

for a principal

Decide where a component library pays for optional features and make lightweight tokens part of the library's public-API review.

## The retention problem **Tree-shaking** is the bundler removing code nothing references. For an Angular library that ships many optional components, it decides whether an app that uses only `<lib-table>` also pays for `<lib-paginator>`. TypeScript references come in two positions: - **Type position** — `paginator: Signal<LibPaginator | undefined>`. Erased during compilation; no effect on the bundle. - **Value position** — `contentChild(LibPaginator)` or `inject(LibPaginator, { optional: true })`. The class object itself is passed at runtime, so the reference survives. A table that looks up an optional paginator by its class therefore keeps `LibPaginator` — class, template, styles — in every app that uses the table, even apps that never write `<lib-paginator>`. The application developer cannot fix this; the reference lives inside the library. ## The pattern The Angular guide describes a four-part fix: 1. **A lightweight token** — a small `abstract class LibPaginatorToken`, with abstract members for whatever the parent needs to call. 2. **The component implements it** — `class LibPaginator extends LibPaginatorToken`. 3. **The component provides itself under the token** — `providers: [{ provide: LibPaginatorToken, useExisting: LibPaginator }]`, so a lookup of the token yields the rendered component instance. 4. **The parent looks up the token** — `contentChild(LibPaginatorToken)` instead of `contentChild(LibPaginator)`. ```ts export abstract class LibPaginatorToken { abstract readonly pageIndex: Signal<number>; } ``` Now the table's code mentions only `LibPaginatorToken`. If `<lib-paginator>` is never used, nothing references `LibPaginator` and the bundler can drop it. The abstract class is retained, but it is only a class declaration with no implementation. ## Why an abstract class and not an InjectionToken The token is used as a **content-query predicate** and as a type. An abstract class does both: it can be passed to `contentChild()` and it types the result, including the abstract API the parent calls. The subclass relationship also makes the compiler check that `LibPaginator` really fulfils the contract. `useExisting` (not `useClass`) matters: the parent must talk to the paginator instance that is on the page, not a newly constructed object. The pattern does not change behaviour for apps that *do* use the paginator. The query still finds the same component instance, now through its alias, and the parent still calls the same methods. What changes is only the direction of the reference: the optional component points at the small token, and the parent points at the token too, so neither piece of code forces the other into the bundle. ## Spotting retention in a library Retention is easy to miss because nothing fails: the app works and is simply larger. Look for it where a library component discovers an optional sibling or child: - `contentChild(SomeComponent)` or `contentChildren(SomeComponent)` with a component class as the predicate; - `inject(SomeComponent, { optional: true })` in a directive or child that may sit inside an optional parent; - a parent that `instanceof`-checks children against a concrete component class. A quick confirmation is to build a minimal consumer app that uses only the parent and inspect its output for the optional component's template strings or selector. If they are there, a value-position reference is keeping them. ## Talking to the child through the token Because the parent sees only the abstract type, every member it uses must be declared on the token: | Parent needs | Declared on the token as | Implemented in | |---|---|---| | Current page | `abstract readonly pageIndex: Signal<number>` | `LibPaginator` | | Reset on sort | `abstract reset(): void` | `LibPaginator` | | Presence check | nothing — the query returns `undefined` | — | The parent must handle absence explicitly, for example `this.paginator()?.pageIndex() ?? 0`, since tree-shaking is only possible because the child may not be there. ## When to use it - You author a **library** and a parent component discovers an **optional** child by class through a content query or `inject()`. - The optional child is **non-trivial** in size — template, styles, dependencies. - The Angular guide notes the pattern is only useful with components; for services, tree-shakable providers already solve retention. It is rarely worth it inside an application, where every component is used on purpose and the bundler's per-route splitting already limits what loads. ## Naming The guide recommends the component's base name with a `Token` suffix — `LibPaginatorToken` for `LibPaginator` — so the relationship stays visible while the two remain distinct.

  • Does inject(LibPaginator, { optional: true }) avoid the retention problem because the dependency is optional?
    No. Optionality changes what happens when no provider is found, but the class is still passed as a runtime value, so the bundler keeps it. The fix is the same as for queries: inject the lightweight token instead, and let the component provide itself under it.
  • Why must the paginator provide itself with useExisting rather than useClass?
    The table needs the paginator instance that is rendered and holding the current page. `useExisting` aliases the token to that element's component instance; `useClass` would ask the injector to construct a separate object of the class, which is not the one on the page.

saying these in an interview costs you the question

  • Type annotations referencing a component keep it in the bundle
  • Making the lookup optional lets the bundler drop the component
  • The pattern removes the token class from the bundle as well
  • The bundler cannot drop a component once any library file imports it
  • The pattern is needed for every service in an application