- Angular
Angularサービスと依存性注入|injectとconstructorの違い
Angularのサービスとは、複数のコンポーネントで共有したいデータや処理をまとめたクラスで、依存性注入(DI)とは、そのクラスのインスタンスを自分で new せずに Angular から受け取る仕組みです。 受け取り方には inject() 関数と constructor の引数の2通りがあり、できることはほぼ同じですが、書ける場所と型の扱いに違いがあります。
この記事では、ng g s で作ったサービスを両方の方法で注入する完成コード、使える場所の比較表、「NG0201」「NG0203」「NG0200」を実際に出して原因と直し方を確かめた結果、そして providedIn: 'root' とコンポーネントの providers でインスタンスが何個作られるかのログをまとめます。
確認環境:Ubuntu 24.04 / Node.js 24.21.0 / Angular CLI 22.2.1・Angular 22.2.1 / TypeScript 6.0.3。動作は ng build と、ng test(Vitest 5.0.3 + jsdom)で確かめています。
サービスと依存性注入の早見表
| やりたいこと | 書き方 | 補足 |
|---|---|---|
| サービスを作る | ng g s cart |
Angular 22.2.1 では @Service() 付きのクラスができる |
| @Injectable で作る | ng g s logger --injectable |
@Injectable({ providedIn: 'root' }) 付きになる |
| inject() で受け取る | private cart = inject(Cart); |
フィールドの初期化かコンストラクタの中で呼ぶ |
| constructor で受け取る | constructor(private cart: Cart) {} |
引数の型がそのまま目印になる |
| アプリ全体で1個にする | @Service() または @Injectable({ providedIn: 'root' }) |
最初に注入されたときに1個だけ作られる |
| コンポーネントごとに1個にする | @Component({ providers: [Counter] }) |
そのコンポーネントと子で共有、破棄も一緒 |
| 無くてもよい依存にする | inject(Draft, { optional: true }) |
見つからないときは null |
| あとから inject() を使う | runInInjectionContext(injector, () => inject(Cart)) |
Injector を先に受け取っておく |
| constructor 形式を一括変換 | ng generate @angular/core:inject |
フィールド + inject() に書き換わる |
エラーコードの早見表です(詳しくは後半で再現します)。
| コード | 意味 | 主な直し方 |
|---|---|---|
| NG0201 | プロバイダーが見つからない | providedIn: 'root' を付ける/providers に登録する |
| NG0203 | 注入コンテキストの外で inject() を呼んだ | フィールドの初期化に移す/runInInjectionContext を使う |
| NG0200 | サービス同士が互いを注入している | 共通部分を3つ目のサービスに出す |
| NG2028 | @Service のクラスで constructor 注入を使った |
inject() に変える/@Injectable にする |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angularサービスと依存性注入|injectとconstructorの違い」
サービスを作る:ng g s の実行結果
@Service() が既定で生成される
Angular 22.2.1 の CLI で ng g s cart を実行した結果です。
npx ng g s cart
CREATE src/app/cart.spec.ts (310 bytes)
CREATE src/app/cart.ts (75 bytes)
import { Service } from '@angular/core';
@Service()
export class Cart {}
ファイル名は cart.ts、クラス名は Cart で、デコレータは @Service() です。公式ドキュメント(Creating and using services)では、@Service は @Injectable({ providedIn: 'root' }) を短く書ける形だと説明されています。
--injectable を付けると、これまでどおりの @Injectable で生成されます。
npx ng g s logger --injectable
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class Logger {}
@Service と @Injectable の違い
公式ドキュメントの比較と、今回のビルド結果を合わせた表です。
| 項目 | @Service() |
@Injectable({ providedIn: 'root' }) |
|---|---|---|
| アプリ全体で1個(root) | 何も書かなくてもそうなる | providedIn: 'root' を書く |
| クラスの中で inject() を使う | できる | できる |
| クラス自身が constructor で依存を受け取る | できない(NG2028) | できる |
| 自動で提供しない設定 | @Service({ autoProvided: false }) |
@Injectable()(providedIn なし) |
useClass などの細かな指定、providedIn: 'platform' |
対象外 | できる |
どちらで作ったサービスも、受け取る側(コンポーネントなど)は inject() と constructor のどちらでも注入できます。この点は次の完成コードで確かめます。
inject() と constructor で注入する完成コード
備品の注文カートを例に、サービス2つ・コンポーネント3つの最小構成を作ります。
サービス:src/app/logger.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class Logger {
readonly lines: string[] = [];
constructor() {
console.log('Logger を作成');
}
log(message: string) {
this.lines.push(message);
console.log('[log]', message);
}
}
サービス:src/app/cart.ts(サービスからサービスを注入)
import { Service, computed, inject, signal } from '@angular/core';
import { Logger } from './logger';
@Service()
export class Cart {
static created = 0;
readonly id = ++Cart.created;
private logger = inject(Logger);
readonly items = signal<string[]>([]);
readonly count = computed(() => this.items().length);
constructor() {
console.log(`Cart #${this.id} を作成`);
}
add(name: string) {
this.items.update((list) => [...list, name]);
this.logger.log(`Cart #${this.id} に ${name} を追加(${this.count()} 件)`);
}
}
static created は、インスタンスが何個作られたかを数えるための目印です。
コンポーネント:src/app/add-button.ts(inject() で注入)
import { Component, inject, input } from '@angular/core';
import { Cart } from './cart';
@Component({
selector: 'app-add-button',
template: `<button type="button" (click)="cart.add(name())">{{ name() }} を追加</button>`,
})
export class AddButton {
// inject() で注入(フィールドの初期化で呼ぶ)
protected cart = inject(Cart);
name = input.required<string>();
}
コンポーネント:src/app/cart-badge.ts(constructor で注入)
import { Component } from '@angular/core';
import { Cart } from './cart';
@Component({
selector: 'app-cart-badge',
template: `<span class="badge">カート: {{ cart.count() }} 件(Cart #{{ cart.id }})</span>`,
})
export class CartBadge {
// constructor で注入(引数の型が目印になる)
constructor(protected cart: Cart) {}
}
親コンポーネント:src/app/app.ts
import { Component } from '@angular/core';
import { AddButton } from './add-button';
import { CartBadge } from './cart-badge';
@Component({
selector: 'app-root',
imports: [AddButton, CartBadge],
template: `
<h1>備品の注文</h1>
<app-cart-badge />
<app-add-button name="ノート" />
<app-add-button name="ペン" />
`,
})
export class App {}
動きを確かめるテスト:src/app/app.spec.ts
import { TestBed } from '@angular/core/testing';
import { App } from './app';
it('inject() と constructor で同じサービスを受け取る', async () => {
console.log('--- コンポーネントを作る前');
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const el = fixture.nativeElement as HTMLElement;
console.log('1)', el.querySelector('.badge')!.textContent);
el.querySelectorAll('button').forEach((b) => b.click());
await fixture.whenStable();
console.log('2)', el.querySelector('.badge')!.textContent);
});
npx ng test --watch=false --reporters=verbose
--- コンポーネントを作る前
Logger を作成
Cart #1 を作成
1) カート: 0 件(Cart #1)
[log] Cart #1 に ノート を追加(1 件)
[log] Cart #1 に ペン を追加(2 件)
2) カート: 2 件(Cart #1)
✓ |demo| src/app/app.spec.ts > inject() と constructor で同じサービスを受け取る
この出力から3つのことが分かります。
- サービスは「最初に注入されたとき」に作られる(「コンポーネントを作る前」の行より後に「を作成」が出ている)
Cartを注入しているコンポーネントは3つ(バッジ1つ、ボタン2つ)あるのに、「Cart #1 を作成」は1回だけ- inject() で受け取ったボタンと、constructor で受け取ったバッジが同じ Cart #1 を見ているので、ボタンで追加した件数がバッジに反映される
inject() と constructor の違い
比較表
| 観点 | inject() | constructor の引数 |
|---|---|---|
| 書く場所 | フィールドの初期化、constructor の中、ファクトリ関数 | constructor の引数だけ |
| クラスの外(関数)で使う | できる(ルートのガード、ファクトリなど) | できない |
| 型の決まり方 | 戻り値から決まる。optional: true なら X | null になる |
引数に書いた型がそのまま使われる |
| 文字列やインターフェースの値を受け取る | inject(TOKEN) と書く |
@Inject(TOKEN) を引数に付ける |
| 継承したとき | 親クラスの依存を super(...) で渡さなくてよい |
子クラスが親の依存を全部受けて super(...) に渡す |
| フィールドの初期化で依存を使う | 上から順に書けば使える | TS2729 になる場合がある(後述) |
@Service() のクラス自身で |
使える | 使えない(NG2028) |
| テストで手動 new する | TestBed 経由で作る |
new Cart(fake) と直接渡せる |
公式ドキュメント(Migrating to the inject function)では、inject() は constructor 注入に比べて型がより正確になり、標準のデコレータとも相性がよい、と説明されています。新しく書くコードは inject() を基本にし、既存の constructor 形式はそのまま動かしつつ少しずつそろえる、という進め方が無理のない選択です。
使える場所(注入コンテキスト)の表
inject() は「注入コンテキスト」と呼ばれる、Angular がクラスを組み立てている最中だけ使えます。公式ドキュメント(Injection context)に挙げられている場所と、今回動かして確かめた場所を合わせて表にします。
| 場所 | inject() | 今回の結果 |
|---|---|---|
フィールドの初期化(private cart = inject(Cart);) |
使える | 完成コードで動作 |
constructor の中(this.cart = inject(Cart);) |
使える | 動作を確認 |
useFactory や InjectionToken の factory 関数 |
使える | 公式ドキュメントに記載 |
runInInjectionContext(injector, fn) の中 |
使える | 下の「直し方」で動作 |
ngOnInit などのライフサイクルメソッド |
使えない | NG0203 |
| クリックなどのイベント処理 | 使えない | NG0203 |
| コンポーネントやサービスと関係のない場所 | 使えない | NG0203 |
constructor の引数は「クラスが作られるとき」にしか書けないので、場所で迷うことがありません。inject() は自由に書ける分、「クラスが作られる瞬間に呼ぶ」という決まりを意識する必要があります。
constructor 形式でつまずきやすい TS2729
constructor の引数で受け取った依存を、フィールドの初期化で使おうとすると、次のコードはビルドで止まります。
@Injectable({ providedIn: 'root' })
export class History {
private prefix = this.logger.lines.length;
constructor(private logger: Logger) {}
}
✘ [ERROR] TS2729: Property 'logger' is used before its initialization. [plugin angular-compiler]
src/app/x2.ts:6:24:
6 │ private prefix = this.logger.lines.length;
╵ ~~~~~~
'logger' is declared here.
src/app/x2.ts:7:14:
7 │ constructor(private logger: Logger) {}
╵ ~~~~~~~~~~~~~~~~~~~~~~
フィールドの初期化は、引数の代入より先に動くためです。inject() なら上から順に評価されるので、次のように書けます。
export class History {
private logger = inject(Logger);
private prefix = this.logger.lines.length;
}
@Service のクラスで constructor 注入を書いた場合(NG2028)
@Service() を付けたクラス自身の constructor に引数を書くと、ビルドで次のメッセージが出ました。
✘ [ERROR] NG2028: @Service class cannot use constructor dependency injection. Use the `inject` function instead. [plugin angular-compiler]
src/app/x1.ts:5:13:
5 │ export class History {
╵ ~~~~~~~
直し方は2つです。メッセージのとおり inject() に書き換えるか、constructor 形式を保ちたいなら @Injectable({ providedIn: 'root' }) に変えます(ng g s 名前 --injectable で最初からその形になります)。
NG0201:No provider を出して直す
再現コード
providedIn を書かない @Injectable() と、autoProvided: false の @Service は、どこかの providers に登録するまで注入できません。
@Injectable()
class Draft { text = '下書き'; }
@Service({ autoProvided: false })
class Scoped { text = 'scoped'; }
@Component({ selector: 'app-no-provider', template: `{{ draft.text }}` })
class NoProvider { draft = inject(Draft); }
@Component({ selector: 'app-no-provider2', template: `{{ s.text }}` })
class NoProvider2 { s = inject(Scoped); }
TestBed.createComponent(...) で作ろうとしたときのメッセージです。
A1) NG0201: No provider found for `_Draft`. Source: DynamicTestModule. Find more at https://v22.angular.dev/errors/NG0201
A2) NG0201: No provider found for `_Scoped`. Source: DynamicTestModule. Find more at https://v22.angular.dev/errors/NG0201
「Draft を作る方法(プロバイダー)が見つかりません」という意味です。クラス名の先頭の _ はビルド時に付いたもので、Source: DynamicTestModule はテスト用の環境から探したことを示しています。
直し方
| 目的 | 直し方 |
|---|---|
| アプリ全体で1個にしたい | @Injectable({ providedIn: 'root' }) にする(または @Service()) |
| このコンポーネントの中だけで使いたい | @Component({ providers: [Draft] }) に登録する |
| アプリ起動時にまとめて登録したい | app.config.ts の providers に入れる |
| 無くても動くようにしたい | inject(Draft, { optional: true }) にして null を扱う |
providers に登録した場合と、optional: true にした場合を動かした結果です。
@Component({ selector: 'app-with-provider', template: `{{ draft.text }} / {{ s.text }}`, providers: [Draft, Scoped] })
class WithProvider { draft = inject(Draft); s = inject(Scoped); }
@Component({ selector: 'app-optional', template: `{{ draft?.text ?? 'なし' }}` })
class OptionalUse { draft = inject(Draft, { optional: true }); }
A3) 下書き / scoped
A4) なし
ビルド時に分かる関連エラー
constructor 形式では、型から「何を注入するか」が決められないとビルドで知らせてくれます。インターフェースや string を引数の型にした場合です。
✘ [ERROR] NG2003: No suitable injection token for parameter 'clock' of class 'X'. Find more at https://next.angular.dev/errors/NG2003
Consider using the @Inject decorator to specify an injection token. [plugin angular-compiler]
src/app/x3.ts:5:22:
5 │ constructor(private clock: Clock) {}
╵ ~~~~~
This type does not have a value, so it cannot be used as injection token.
インターフェースは実行時には存在しないので、目印(トークン)になれません。InjectionToken を作って inject(CLOCK) で受け取ります。
また、デコレータの無いクラスを providers に入れ、そのクラスが constructor で依存を受け取っていると、次のメッセージになりました。
✘ [ERROR] NG2005: The class 'Plain' cannot be created via dependency injection, as it does not have an Angular decorator. This will result in an error at runtime.
Either add the @Injectable() decorator to 'Plain', or configure a different provider (such as a provider with 'useFactory').
サービスにするクラスには、@Service() か @Injectable() を付けておきましょう。
NG0203:inject() を呼ぶ場所を直す
再現コード
ngOnInit の中と、クリック処理の中で inject() を呼びます。
@Component({ selector: 'app-late', template: `` })
class LateInject implements OnInit {
ngOnInit() {
const cart = inject(Cart);
cart.add('ノート');
}
}
@Component({ selector: 'app-click', template: `<button (click)="onClick()">追加</button>` })
class ClickInject {
onClick() {
try { inject(Cart).add('ペン'); } catch (e) { console.log('B2)', (e as Error).message); }
}
}
B1) NG0203: The `_Cart` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203
B2) NG0203: The `_Cart` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203
メッセージには、使える場所(constructor、ファクトリ関数、フィールドの初期化、runInInjectionContext に渡した関数)がそのまま書かれています。ビルドは通り、実行したときに分かるエラーです。
直し方
基本は「フィールドで受け取っておき、あとで使う」です。どうしても後から注入したい場合は、Injector を先に受け取っておき、runInInjectionContext を使います。
@Component({ selector: 'app-fixed', template: `<button (click)="onClick()">追加</button>` })
class FixedInject implements OnInit {
private cart = inject(Cart); // 直し方1: フィールドで受け取っておく
private injector = inject(Injector); // 直し方2: Injector を受け取っておく
ngOnInit() { this.cart.add('ノート'); }
onClick() {
runInInjectionContext(this.injector, () => inject(Cart).add('ペン'));
}
}
コンポーネントを作ってボタンを押した結果です。
Logger を作成
Cart #1 を作成
[log] Cart #1 に ノート を追加(1 件)
[log] Cart #1 に ペン を追加(2 件)
| 状況 | 向いている直し方 |
|---|---|
| ライフサイクルメソッドやイベント処理で使いたい | フィールドで inject() しておき、this.cart を使う |
| 共通の関数の中で inject() を使っている | その関数を、フィールドの初期化か constructor から呼ぶ |
| 条件によって後から注入したい | inject(Injector) を持っておき runInInjectionContext |
| 自作の関数が注入コンテキスト前提だと明示したい | 関数の先頭で assertInInjectionContext(関数名) を呼ぶ(公式ドキュメントに記載) |
NG0200:循環依存を出して直す
再現コード
注文サービスが在庫サービスを、在庫サービスが注文サービスを注入しています。
@Injectable({ providedIn: 'root' })
class OrderService {
private stock = inject(StockService);
total() { return this.stock.reserved() + 1; }
}
@Injectable({ providedIn: 'root' })
class StockService {
private order = inject(OrderService);
reserved() { return 1; }
}
TestBed.inject(OrderService) で取り出そうとした結果です。
C1) NG0200: Circular dependency detected for `_OrderService`. Source: DynamicTestModule. Path: _OrderService -> _StockService -> _OrderService. Find more at https://v22.angular.dev/errors/NG0200
Path: に、どの順で注入が一周したかが表示されます。OrderService を作るには StockService が必要で、StockService を作るには OrderService が必要、という輪になっているため、どちらも作り終えることができません。
直し方:共通部分を3つ目のサービスに出す
両方が必要としているデータや処理を、別のサービスに切り出します。
@Injectable({ providedIn: 'root' })
class ReservationStore { readonly reserved = signal(1); }
@Injectable({ providedIn: 'root' })
class OrderService2 {
private store = inject(ReservationStore);
total() { return this.store.reserved() + 1; }
}
@Injectable({ providedIn: 'root' })
class StockService2 {
private store = inject(ReservationStore);
reserved() { return this.store.reserved(); }
}
C2) 2 1
依存の向きが「OrderService2 → ReservationStore ← StockService2」と一方向になり、輪がなくなりました。
| 原因のパターン | 直し方 |
|---|---|
| 2つのサービスが互いのデータを読む | データを持つサービスを別に作り、両方からそれを注入する |
| 片方がもう片方に「通知」したいだけ | 通知する側がシグナルやイベントを公開し、受ける側だけが注入する |
| 1つのサービスに役割が集まりすぎている | 役割ごとに分け、依存が一方向になるよう並べ直す |
Path: に表示された名前を紙に書き出し、矢印の向きを1本だけ逆にできないか考えると、切り出すべき部分が見つかりやすくなります。
providedIn: root と providers でインスタンスは何個できるか
確かめるコード:src/app/scope.spec.ts
作成と破棄のたびにログを出す Counter サービスを、3通りの方法で注入します。
import { Component, Injectable, OnDestroy, inject, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
@Injectable({ providedIn: 'root' })
class Counter implements OnDestroy {
static created = 0;
readonly id = ++Counter.created;
readonly value = signal(0);
constructor() { console.log(` Counter #${this.id} を作成`); }
ngOnDestroy() { console.log(` Counter #${this.id} を破棄`); }
}
// (1) providers なし → root の1個を共有
@Component({ selector: 'app-shared', template: `<button (click)="c.value.update(plusOne)">#{{ c.id }}: {{ c.value() }}</button>` })
class SharedPanel { c = inject(Counter); plusOne = (n: number) => n + 1; }
// (2) コンポーネントの providers → コンポーネント1個につき1個
@Component({ selector: 'app-own', providers: [Counter], template: `<button (click)="c.value.update(plusOne)">#{{ c.id }}: {{ c.value() }}</button>` })
class OwnPanel { c = inject(Counter); plusOne = (n: number) => n + 1; }
@Component({ selector: 'app-child', template: `子が見ている Counter: #{{ c.id }}` })
class Child { c = inject(Counter); }
@Component({ selector: 'app-own2', providers: [Counter], imports: [Child], template: `#{{ c.id }} <app-child />` })
class OwnWithChild { c = inject(Counter); }
@Component({ selector: 'app-host-shared', imports: [SharedPanel], template: `<app-shared /><app-shared /><app-shared />` })
class HostShared {}
@Component({ selector: 'app-host-own', imports: [OwnPanel], template: `<app-own /><app-own /><app-own />` })
class HostOwn {}
@Component({ selector: 'app-host-tree', imports: [OwnWithChild, Child], template: `<app-own2 /> | 外側: <app-child />` })
class HostTree {}
const texts = (el: HTMLElement) => Array.from(el.querySelectorAll('button')).map((b) => b.textContent);
it('インスタンスの数を数える', async () => {
console.log('(1) providedIn: root を3か所で注入');
const a = TestBed.createComponent(HostShared); await a.whenStable();
(a.nativeElement.querySelector('button') as HTMLButtonElement).click(); await a.whenStable();
console.log(' 表示:', texts(a.nativeElement));
console.log('(2) コンポーネントの providers に書いて3つ並べる');
const b = TestBed.createComponent(HostOwn); await b.whenStable();
(b.nativeElement.querySelector('button') as HTMLButtonElement).click(); await b.whenStable();
console.log(' 表示:', texts(b.nativeElement));
console.log('(3) providers を書いたコンポーネントの子と、外側');
const c = TestBed.createComponent(HostTree); await c.whenStable();
console.log(' 表示:', c.nativeElement.textContent);
console.log('(4) コンポーネントを破棄');
b.destroy(); c.destroy(); a.destroy();
console.log('(5) 作成された合計:', Counter.created);
});
実行結果
(1) providedIn: root を3か所で注入
Counter #1 を作成
表示: [ '#1: 1', '#1: 1', '#1: 1' ]
(2) コンポーネントの providers に書いて3つ並べる
Counter #2 を作成
Counter #3 を作成
Counter #4 を作成
表示: [ '#2: 1', '#3: 0', '#4: 0' ]
(3) providers を書いたコンポーネントの子と、外側
Counter #5 を作成
表示: #5 子が見ている Counter: #5 | 外側: 子が見ている Counter: #1
(4) コンポーネントを破棄
Counter #2 を破棄
Counter #3 を破棄
Counter #4 を破棄
Counter #5 を破棄
(5) 作成された合計: 5
Counter #1 を破棄
最後の「Counter #1 を破棄」は、テストが終わってアプリ全体が片付けられるときに出た行です。
結果の読み方
| 登録のしかた | 作られる数 | 共有される範囲 | 破棄されるとき |
|---|---|---|---|
providedIn: 'root'(または @Service()) |
アプリ全体で1個 | どこで注入しても同じ(3つとも #1: 1) |
アプリが終わるとき |
コンポーネントの providers: [Counter] |
コンポーネント1個につき1個(3つ並べると3個) | そのコンポーネントと、その子 | そのコンポーネントが破棄されるとき |
(3) の結果も大切です。providers を書いたコンポーネントの子は親と同じ #5 を受け取り、その外側にある同じ子コンポーネントは root の #1 を受け取りました。Angular は、注入を求めたコンポーネントから親へ向かって順にプロバイダーを探し、見つからなければ root までさかのぼる、という動きをしています。
どちらに登録するかの判断表
| 状況 | 選ぶ登録先 |
|---|---|
| ログイン中の会員情報、設定、カートなど、アプリ全体で1つの状態 | providedIn: 'root' / @Service() |
| 画面に同じ部品を複数並べ、それぞれ別の状態を持たせたい(入力フォーム、絞り込み条件など) | コンポーネントの providers |
| 画面を閉じたら状態も片付けたい | コンポーネントの providers(ngOnDestroy が呼ばれる) |
| 迷ったとき | まず root。共有されて困る状態が出てきたら providers に移す |
「root のサービスを providers にも書く」と、意図せず別のインスタンスができる点に注意してください。「追加したはずのデータが別の画面に出ない」ときは、providers に同じサービスを書いていないかを確認すると原因が見つかることがあります。
constructor 形式を inject() に一括変換する
Angular には、constructor 注入を inject() に書き換える移行コマンドがあります。constructor 形式のサービスを1つ追加して実行しました。
// 変換前:src/app/order-history.ts
import { Injectable } from '@angular/core';
import { Cart } from './cart';
import { Logger } from './logger';
@Injectable({ providedIn: 'root' })
export class OrderHistory {
private saved: string[][] = [];
constructor(
private cart: Cart,
private readonly logger: Logger,
) {
this.logger.log('OrderHistory を作成');
}
save() {
this.saved.push(this.cart.items());
this.logger.log(`${this.saved.length} 回目の注文を保存`);
}
}
npx ng generate @angular/core:inject --defaults
UPDATE src/app/cart-badge.ts (290 bytes)
UPDATE src/app/order-history.ts (491 bytes)
変換差分です。
-import { Injectable } from '@angular/core';
+import { Injectable, inject } from '@angular/core';
import { Cart } from './cart';
import { Logger } from './logger';
@Injectable({ providedIn: 'root' })
export class OrderHistory {
+ private cart = inject(Cart);
+ private readonly logger = inject(Logger);
+
private saved: string[][] = [];
- constructor(
- private cart: Cart,
- private readonly logger: Logger,
- ) {
+ constructor() {
this.logger.log('OrderHistory を作成');
}
export class CartBadge {
- // constructor で注入(引数の型が目印になる)
- constructor(protected cart: Cart) {}
+ protected cart = inject(Cart);
}
private / protected / readonly はそのまま引き継がれ、中身が空になった constructor は取り除かれました(constructor に付けていたコメントも一緒に消えています)。変換後も ng build は通りました。
公式ドキュメント(Migrating to the inject function)に載っているオプションです。
| オプション | 役割 |
|---|---|
path |
変換する範囲のフォルダ |
migrateAbstractClasses |
抽象クラスも変換する(既定は無効。手直しが必要になる場合がある) |
backwardsCompatibleConstructors |
継承先のコンパイルエラーを避けるため、constructor の引数の形を残す |
nonNullableOptional |
@Optional 付きの引数の型を、これまでと同じ(null を含まない)扱いにする |
チェックリスト
- [ ] サービスに
@Service()か@Injectable({ providedIn: 'root' })を付けた - [ ] inject() はフィールドの初期化か constructor の中で呼んでいる
- [ ]
ngOnInitやイベント処理では、受け取っておいたフィールド(this.cart)を使っている - [ ]
@Service()のクラス自身は constructor の引数で依存を受け取っていない - [ ] サービス同士が互いを注入していない(輪になっていない)
- [ ] root のサービスを、意図せずコンポーネントの
providersにも書いていない - [ ] コンポーネントごとに分けたい状態だけ
providersに登録している
よくある質問
Q. inject() と constructor はどちらを使えばよいですか?
A. 新しく書くコードは inject() が扱いやすい選択です。フィールドの初期化で依存を使える、継承時に super(...) へ渡さなくてよい、optional: true で型に null が入る、といった利点があります。また Angular 22.2.1 の ng g s が生成する @Service() のクラスは inject() が前提です。constructor 形式も引き続き動くので、既存コードは ng generate @angular/core:inject で少しずつそろえられます。
Q. providedIn: 'root' と @Service() は何が違いますか?
A. どちらもアプリ全体で1個のインスタンスになります。@Service() は何も書かなくても root に提供される短い書き方で、クラス自身の constructor 注入や useClass などの細かな指定は対象外です。それらが必要なときは @Injectable を使います。
Q. サービスはいつ作られますか? A. 最初に注入されたときです。今回のログでも、「コンポーネントを作る前」の行のあと、コンポーネントが注入を求めた時点で「Logger を作成」「Cart #1 を作成」が出ました。どこからも注入されないサービスは作られません。
Q. テストでサービスを差し替えるにはどうしますか?
A. TestBed.configureTestingModule({ providers: [{ provide: Cart, useValue: 代わりのオブジェクト }] }) のように登録すると、inject() でも constructor でも、その代わりの値が注入されます。完成コードの App で id: 99 の代わりのカートを登録したところ、バッジは「カート: 2 件(Cart #99)」と表示されました。受け取り方を変えても差し替えの書き方は共通です。
まとめ
- サービスは共有したいデータと処理を置くクラス、DI はそのインスタンスを Angular から受け取る仕組み
- Angular 22.2.1 の
ng g sは@Service()を生成する。--injectableで@Injectable({ providedIn: 'root' })になる - inject() はフィールドの初期化と constructor の中で呼ぶ。
ngOnInitやクリック処理で呼ぶと NG0203 - NG0201 は「提供されていない」。
providedIn: 'root'を付けるか、providersに登録する - NG0200 は「互いに注入している」。
Path:を読み、共通部分を3つ目のサービスに出す - root は全体で1個、コンポーネントの providers はコンポーネントごとに1個。子は親のものを受け取り、破棄も親と一緒
- constructor 形式は
ng generate @angular/core:injectでまとめて inject() に書き換えられる
参考・出典
- Angular 公式ドキュメント「Creating and using services」 https://angular.dev/guide/di/creating-and-using-services
- Angular 公式ドキュメント「Injection context」 https://angular.dev/guide/di/dependency-injection-context
- Angular 公式ドキュメント「Migrating to the inject function」 https://angular.dev/reference/migrations/inject-function
- Angular API リファレンス「Service」 https://angular.dev/api/core/Service
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Angularサービスと依存性注入|injectとconstructorの違い」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。