Skip to the solution
MediumFrontend11 min read

What is the difference between constructor-based DI and the inject() function?

446 views

The problem

Question presented to candidate: "Here are two versions of the same component: one takes Logger and Api as constructor parameters, the other has logger = inject(Logger) fields. Explain what each one compiles to, where inject() is allowed to run, and what changes when the class extends a base class."

What a strong answer should cover:

  • Constructor injection: the Angular compiler reads parameter types at build time and generates a factory calling ɵɵdirectiveInject(Type, flags) per parameter; decorators like @Optional() and @SkipSelf() become bit flags.
  • inject(): the factory takes no arguments; the inject() calls run inside the class while Angular is constructing it, using the same resolution machinery and the same options (optional, self, skipSelf, host).
  • Injection context: inject() only works during construction (field initializers, constructor), in provider factories, in functional guards/interceptors/initializers, or inside runInInjectionContext; elsewhere it throws NG0203.
  • Inheritance: with constructor DI every subclass must repeat and forward the parent's dependencies through super(...); with inject() the base class injects for itself and super() takes no arguments.
  • Field-initializer ordering: inject() fields can be used by later fields; a field that reads a constructor parameter property is a TypeScript error (TS2729).
  • Without the Angular compiler (plain transpile), constructor parameter types are lost and DI fails with NG0202; inject() does not depend on type metadata.

Clarifying questions expected:

  • "Do you want the generated code, or just the behavioural differences?"

Code / implementation expected: Yes — the same class in both styles, ideally with a base class, and the compiled factory lines.

  • di
  • dependency-injection

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read · 11 sections

Read the code

Angularinject() in field initializers, a base class, and the NG0203 injection-context error
import { Component, Directive, Injectable, Injector, inject, runInInjectionContext, signal } from "@angular/core";

const log: string[] = [];

@Injectable({ providedIn: "root" })
export class Logger {
  write(msg: string) { log.push(msg); }
}

@Injectable({ providedIn: "root" })
export class Api {
  readonly baseUrl = "https://api.example.com";
}

// 1. inject() works in field initializers, so later fields can use earlier ones.
// 2. A base class grabs its own deps: subclasses never pass anything to super().
@Directive()
export abstract class BasePage {
  protected logger = inject(Logger);
  protected api = inject(Api);
  protected endpoint = `${this.api.baseUrl}/orders`; // api is already set here
}

@Component({
  selector: "app-root",
  template: `
    <p>endpoint: {{ endpoint }}</p>
    <button (click)="injectInHandler()">inject() inside a click handler</button>
    <button (click)="injectWithContext()">same, wrapped in runInInjectionContext</button>
    <ol>
      @for (line of lines(); track $index) { <li>{{ line }}</li> }
    </ol>
  `,
})
export class AppComponent extends BasePage {
  private injector = inject(Injector); // capture the injector while we are still in context
  lines = signal<string[]>([]);

  constructor() {
    super(); // no arguments: the base class injected for itself
    this.logger.write("constructor: logger came from the base class");
    this.lines.set([...log]);
  }

  injectInHandler() {
    try {
      inject(Api); // not an injection context any more
    } catch (e) {
      const msg = (e as Error).message; // "NG0203: The `Api` token injection failed. `inject()` function must..."
      this.logger.write(`${msg.slice(0, 6)}: ${msg.split(". ")[1]}`);
    }
    this.lines.set([...log]);
  }

  injectWithContext() {
    const api = runInInjectionContext(this.injector, () => inject(Api));
    this.logger.write(`runInInjectionContext gave ${api.baseUrl}`);
    this.lines.set([...log]);
  }
}
AngularThe same base class written with constructor injection (needs the Angular compiler)
import { Component, Directive, Injectable, Optional, SkipSelf } from "@angular/core";

@Injectable({ providedIn: "root" })
export class Logger {
  write(msg: string) { console.log(msg); }
}

@Injectable({ providedIn: "root" })
export class Api {
  readonly baseUrl = "https://api.example.com";
}

// Constructor style: the compiler reads the parameter TYPES and writes the factory:
//   new AppComponent(ɵɵdirectiveInject(Logger), ɵɵdirectiveInject(Api, 12))
// (12 = SkipSelf 4 + Optional 8). This needs the Angular compiler (ng build / ngc);
// a plain TypeScript transpile drops the types and fails with NG0202.
@Directive()
export abstract class BasePage {
  protected endpoint: string;
  constructor(protected logger: Logger, protected api: Api | null) {
    this.endpoint = `${api?.baseUrl ?? "none"}/orders`;
  }
}

@Component({
  selector: "app-root",
  template: `<p>endpoint: {{ endpoint }}</p>`,
})
export class AppComponent extends BasePage {
  // Every subclass has to repeat the parent dependencies and forward them.
  constructor(logger: Logger, @Optional() @SkipSelf() api: Api | null) {
    super(logger, api);
    this.logger.write("constructor: deps forwarded to super()");
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 58 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

How does the new control flow @if / @for / @switch replace *ngIf and *ngFor?

Continue