Skip to content

Commit 235befd

Browse files
committed
feat(di): Angular-shaped InjectOptions on inject() and Injector.get()
optional resolves to null instead of throwing for unknown tokens (a found-but-misconfigured provider still throws); skipSelf starts at the parent, escaping a child scope's shadowing entry; self refuses parent fallthrough. self+skipSelf throws. host is deliberately absent - it is an Angular component-tree concept with no analog in this hierarchy. get()'s former second parameter - the legacy per-call ctorArguments bag - had exactly one caller, the facade's own resolve(name, bag). It moves to a protected getWithLegacyArguments channel, so the public get() aligns with Angular's shape today rather than after the legacy paths are deleted.
1 parent d839373 commit 235befd

7 files changed

Lines changed: 148 additions & 8 deletions

File tree

dependency-injection.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ string name — all three return the same instance. The string forms exist for
9595
interoperability with the legacy registry; use the class token whenever one
9696
exists.
9797

98+
Both `inject()` and `get()` take Angular-shaped options as their second
99+
argument:
100+
101+
```ts
102+
inject(DoctorService, { optional: true }); // DoctorService | null — no throw
103+
inject("logger", { skipSelf: true }); // start at the parent: escapes a
104+
// child scope's shadowing entry
105+
inject("options", { self: true }); // this level only — no fallthrough
106+
```
107+
108+
`optional` covers not-found only; a found-but-misconfigured provider still
109+
throws. `self` and `skipSelf` cannot be combined. There is deliberately no
110+
`host` option: it is an Angular component-tree concept with no analog in the
111+
CLI's injector hierarchy.
112+
98113
Registering: providers
99114
----------------------
100115

lib/common/di/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { Injector } from "./injector";
2+
export type { InjectOptions } from "./injector";
23
export { inject, runInInjectionContext } from "./inject";
34
export { forwardRef, resolveForwardRef } from "./forward-ref";
45
export {

lib/common/di/inject.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Injector } from "./injector";
1+
import type { Injector, InjectOptions } from "./injector";
22
import type { ProviderToken } from "./providers";
33

44
// Sync-only by design (no AsyncLocalStorage): `current` is restored in a
@@ -7,7 +7,19 @@ import type { ProviderToken } from "./providers";
77
// later lookups.
88
let current: Injector | null = null;
99

10-
export function inject<T = any>(token: ProviderToken<T>): T {
10+
export function inject<T = any>(token: ProviderToken<T>): T;
11+
export function inject<T = any>(
12+
token: ProviderToken<T>,
13+
options: InjectOptions & { optional: true },
14+
): T | null;
15+
export function inject<T = any>(
16+
token: ProviderToken<T>,
17+
options: InjectOptions,
18+
): T;
19+
export function inject<T = any>(
20+
token: ProviderToken<T>,
21+
options?: InjectOptions,
22+
): T | null {
1123
if (!current) {
1224
throw new Error(
1325
"inject() can only be called from an injection context — a field " +
@@ -16,7 +28,7 @@ export function inject<T = any>(token: ProviderToken<T>): T {
1628
"the Injector itself and use injector.get() for late lookups.",
1729
);
1830
}
19-
return current.get(token);
31+
return current.get(token, options);
2032
}
2133

2234
export function runInInjectionContext<T>(injector: Injector, fn: () => T): T {

lib/common/di/injector.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ import type { Provider, ProviderToken, Type } from "./providers";
66

77
type TokenKey = string | Function;
88

9+
export interface InjectOptions {
10+
/** Resolve to null instead of throwing when the token is not registered. */
11+
optional?: boolean;
12+
/** Resolve at this injector's level only — no parent fallthrough. */
13+
self?: boolean;
14+
/**
15+
* Start resolution at the parent — escapes a child scope's shadowing
16+
* entry (e.g. a hook payload key that collides with a service name).
17+
*/
18+
skipSelf?: boolean;
19+
}
20+
921
type ProviderKind = "value" | "class" | "factory" | "lazyClass" | "legacyClass";
1022

1123
interface IProviderRecord {
@@ -56,10 +68,41 @@ export class Injector {
5668
*/
5769
// T defaults to any so string tokens (which give inference no source and
5870
// would otherwise land on unknown) stay ergonomic during the migration.
71+
public get<T = any>(token: ProviderToken<T>): T;
72+
public get<T = any>(
73+
token: ProviderToken<T>,
74+
options: InjectOptions & { optional: true },
75+
): T | null;
76+
public get<T = any>(token: ProviderToken<T>, options: InjectOptions): T;
5977
public get<T = any>(
6078
token: ProviderToken<T>,
79+
options?: InjectOptions,
80+
): T | null {
81+
if (options && options.self && options.skipSelf) {
82+
throw new Error("inject options cannot combine self and skipSelf");
83+
}
84+
token = resolveForwardRef(token);
85+
const found = this.findRecord(token, options);
86+
if (!found) {
87+
if (options && options.optional) {
88+
return null;
89+
}
90+
throw new Error("unable to resolve " + displayNameOf(token));
91+
}
92+
return found.owner.instantiate(found.record);
93+
}
94+
95+
/**
96+
* The legacy facade's channel for Yok's `resolve(name, bag)` sites: the
97+
* bag applies to the construction this call itself triggers, with raw
98+
* own-key semantics, and never propagates to nested resolutions. Not part
99+
* of the public API — new code passes per-call providers to
100+
* createInstance instead.
101+
*/
102+
protected getWithLegacyArguments(
103+
token: ProviderToken,
61104
ctorArguments?: { [key: string]: any },
62-
): T {
105+
): any {
63106
token = resolveForwardRef(token);
64107
const found = this.findRecord(token);
65108
if (!found) {
@@ -237,12 +280,20 @@ export class Injector {
237280
return name !== undefined ? this.providers.get(name) : undefined;
238281
}
239282

283+
// self/skipSelf apply to the entry level only: the parent walk below is
284+
// always an ordinary full lookup from that injector on.
240285
private findRecord(
241286
token: ProviderToken,
287+
options?: InjectOptions,
242288
): { record: IProviderRecord; owner: Injector } | undefined {
243-
const local = this.findRecordLocal(token);
244-
if (local) {
245-
return { record: local, owner: this };
289+
if (!options || !options.skipSelf) {
290+
const local = this.findRecordLocal(token);
291+
if (local) {
292+
return { record: local, owner: this };
293+
}
294+
if (options && options.self) {
295+
return undefined;
296+
}
246297
}
247298
return this.parent ? this.parent.findRecord(token) : undefined;
248299
}

lib/common/yok.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ export class Yok extends Injector implements IInjector {
505505
// track these instances for disposal either.
506506
return this.createInstance(<Function>param, [], ctorArguments);
507507
}
508-
return this.get(<string>param, ctorArguments);
508+
return this.getWithLegacyArguments(<string>param, ctorArguments);
509509
}
510510

511511
/* Regex to match dynamic calls in the following format:

lib/contracts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type { IContractOptions } from "../common/di/contract";
1414
export { inject, runInInjectionContext } from "../common/di/inject";
1515
export { forwardRef, resolveForwardRef } from "../common/di/forward-ref";
1616
export { Injector } from "../common/di/injector";
17+
export type { InjectOptions } from "../common/di/injector";
1718
export { provide, provideLazy } from "../common/di/providers";
1819
export type {
1920
Provider,

test/di.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,66 @@ describe("di: forwardRef", () => {
187187
});
188188
});
189189

190+
describe("di: inject options", () => {
191+
it("optional resolves to null for an unknown token, and normally for a known one", () => {
192+
const injector = new Injector([provide(Greeter, GreeterImpl)]);
193+
194+
assert.isNull(injector.get("nothing-here", { optional: true }));
195+
assert.instanceOf(injector.get(Greeter, { optional: true }), GreeterImpl);
196+
197+
runInInjectionContext(injector, () => {
198+
assert.isNull(inject("nothing-here", { optional: true }));
199+
});
200+
});
201+
202+
it("optional does not swallow a found-but-misconfigured record", () => {
203+
const injector = new Injector([
204+
{ provide: "brokenLiteral", useValue: {}, shared: false },
205+
]);
206+
207+
assert.throws(
208+
() => injector.get("brokenLiteral", { optional: true }),
209+
/no resolver registered/,
210+
);
211+
});
212+
213+
it("skipSelf escapes a child scope's shadowing entry", () => {
214+
const root = new Injector([
215+
{ provide: "logger", useValue: { from: "root" } },
216+
]);
217+
const child = root.createChild([
218+
{ provide: "logger", useValue: { from: "payload" } },
219+
]);
220+
221+
assert.equal(child.get<any>("logger").from, "payload");
222+
assert.equal(child.get<any>("logger", { skipSelf: true }).from, "root");
223+
// On the root there is no parent to skip to.
224+
assert.isNull(root.get("logger", { skipSelf: true, optional: true }));
225+
});
226+
227+
it("self refuses parent fallthrough", () => {
228+
const root = new Injector([{ provide: "rootOnly", useValue: { tag: 1 } }]);
229+
const child = root.createChild([
230+
{ provide: "childOnly", useValue: { tag: 2 } },
231+
]);
232+
233+
assert.equal(child.get<any>("childOnly", { self: true }).tag, 2);
234+
assert.throws(
235+
() => child.get("rootOnly", { self: true }),
236+
/unable to resolve/,
237+
);
238+
assert.isNull(child.get("rootOnly", { self: true, optional: true }));
239+
});
240+
241+
it("rejects combining self and skipSelf", () => {
242+
const injector = new Injector();
243+
assert.throws(
244+
() => injector.get("anything", { self: true, skipSelf: true }),
245+
/cannot combine self and skipSelf/,
246+
);
247+
});
248+
});
249+
190250
describe("di: cycles", () => {
191251
it("reports the full resolution path", () => {
192252
class CycleA {

0 commit comments

Comments
 (0)