Skip to content

Commit e1af90e

Browse files
committed
feat(di): add InjectionToken for non-class dependencies
@contract covers services, which have an abstract class to decorate. Registrations whose value is a module namespace object or a plain value have nothing to decorate, so they could not be given a typed token at all. InjectionToken's description doubles as the legacy registry name, so a token is a typed alias over an existing registration: resolution is token identity first, then the name, per injector level. Names are minted in the registry @contract already uses, so the two kinds cannot claim the same name. Mints XCODE and PBXPROJ_DOM_XCODE over the existing lib/node/ registrations; the injection sites are not migrated yet.
1 parent ed947c0 commit e1af90e

11 files changed

Lines changed: 389 additions & 32 deletions

File tree

dependency-injection.md

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,45 @@ Rules:
6363
- Implementations do not become tokens by extending or implementing a contract;
6464
only the decorated class itself is a token.
6565

66+
Tokens for non-classes: `InjectionToken`
67+
----------------------------------------
68+
69+
Some registrations have no class to decorate — an imported module namespace, a
70+
plain value, a function. `InjectionToken` is the typed key for those:
71+
72+
```ts
73+
import { InjectionToken, inject } from "nativescript/contracts";
74+
75+
export const XCODE = new InjectionToken<typeof import("nativescript-dev-xcode")>(
76+
"xcode",
77+
);
78+
79+
class ProjectPatcher {
80+
private xcode = inject(XCODE); // typeof import("nativescript-dev-xcode")
81+
}
82+
```
83+
84+
The description **is** the registry name, exactly as a contract's name is, so a
85+
token is a typed alias onto an existing registration and nothing has to change
86+
where the value is registered — `injector.register("xcode", xcode)` keeps
87+
serving `inject(XCODE)`. A leading `$` in the description is stripped.
88+
89+
Names are minted in the same registry `@Contract` uses, so a token and a
90+
contract cannot claim the same name: the second one **throws at load time**,
91+
rather than silently aliasing one registration under two tokens.
92+
93+
Tokens are used anywhere a contract class is — `inject()`, `get()`, `provide()`
94+
and the provider literals:
95+
96+
```ts
97+
{ provide: XCODE, useValue: xcode }
98+
{ provide: XCODE, useFactory: () => require("nativescript-dev-xcode") }
99+
```
100+
101+
Prefer a `@Contract` class when the dependency is a service: it also carries
102+
the service's shape. Reach for `InjectionToken` only when there is nothing to
103+
decorate.
104+
66105
Resolving: `inject()` and `Injector`
67106
------------------------------------
68107

@@ -90,10 +129,10 @@ class EnvironmentChecker {
90129
}
91130
```
92131

93-
`Injector.get()` accepts a contract class, a string name, or a `$`-prefixed
94-
string name — all three return the same instance. The string forms exist for
95-
interoperability with the legacy registry; use the class token whenever one
96-
exists.
132+
`Injector.get()` accepts a contract class, an `InjectionToken`, a string name,
133+
or a `$`-prefixed string name — all of them return the same instance. The
134+
string forms exist for interoperability with the legacy registry; use the token
135+
whenever one exists.
97136

98137
Both `inject()` and `get()` take Angular-shaped options as their second
99138
argument:
@@ -155,7 +194,8 @@ instance. Transient instances are still retained by the container so
155194
String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`)
156195
— that is how the legacy facade registers, and how per-call overrides address
157196
not-yet-migrated dependencies. New registrations should mint a `@Contract`
158-
token instead of a new string name.
197+
class, or an `InjectionToken` when there is no class to decorate, instead of a
198+
new string name.
159199

160200
For per-call construction with overrides (a fresh instance of a class with some
161201
dependencies replaced), use `createInstance`:
@@ -173,16 +213,17 @@ owns them and never see the per-call providers.
173213
Resolution semantics
174214
--------------------
175215

176-
- Lookup is **class object first, token name on a miss**, checked per injector
177-
level before delegating to the parent. Both keys index the same provider
178-
record, so re-registering a service by its string name (as plugins are
179-
documented to do with `$logger`) stays visible to `inject(Logger)` consumers.
216+
- Lookup is **token identity first, token name on a miss**, checked per
217+
injector level before delegating to the parent. Both keys index the same
218+
provider record, so re-registering a service by its string name (as plugins
219+
are documented to do with `$logger`) stays visible to `inject(Logger)`
220+
consumers. This holds for `@Contract` classes and `InjectionToken`s alike.
180221
- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")`
181222
are the same registration.
182-
- The name fallback also makes **duplicated contract copies interchangeable**:
183-
if an extension's dependency tree carries its own copy of a contract class,
184-
that copy resolves to the same provider by name. "Works locally, breaks when
185-
installed" is not a failure mode of this design.
223+
- The name fallback also makes **duplicated token copies interchangeable**: if
224+
an extension's dependency tree carries its own copy of a contract class or
225+
injection token, that copy resolves to the same provider by name. "Works
226+
locally, breaks when installed" is not a failure mode of this design.
186227
- Cyclic dependencies fail with the full resolution path
187228
(`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`).
188229

@@ -284,6 +325,13 @@ contract and every existing caller sees it.
284325
| `Prompter` | `prompter` |
285326
| `TempService` | `tempService` |
286327

328+
And the injection tokens, for registrations that are not classes:
329+
330+
| Token | Legacy name | Value |
331+
|---|---|---|
332+
| `XCODE` | `xcode` | the `nativescript-dev-xcode` module |
333+
| `PBXPROJ_DOM_XCODE` | `pbxprojDomXcode` | the `pbxproj-dom/xcode` module |
334+
287335
Related guides
288336
--------------
289337

lib/common/di/contract.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,30 @@ export interface IContractOptions {
1919
// Per module instance on purpose: a duplicated CLI copy in an extensions tree
2020
// carries its own registry, so contracts redeclared by another copy never
2121
// false-positive here.
22-
const mintedNames = new Map<string, Function>();
22+
const mintedNames = new Map<string, object>();
23+
24+
function describeOwner(owner: object): string {
25+
if (typeof owner === "function") {
26+
return `contract '${owner.name || "<anonymous>"}'`;
27+
}
28+
return "an injection token";
29+
}
30+
31+
/**
32+
* Claims a token name for `owner`. Both `@Contract` and `InjectionToken` mint
33+
* here so the two kinds share one namespace: a contract and a token that claim
34+
* the same name would be two tokens silently aliasing one registration.
35+
*/
36+
export function mintTokenName(name: string, owner: object): void {
37+
const existing = mintedNames.get(name);
38+
if (existing && existing !== owner) {
39+
throw new Error(
40+
`Token name '${name}' is already used by ${describeOwner(existing)}. ` +
41+
`Token names must be unique — a duplicate silently aliases two tokens.`,
42+
);
43+
}
44+
mintedNames.set(name, owner);
45+
}
2346

2447
/**
2548
* Marks an abstract class as a DI token. The decorated class resolves by
@@ -31,15 +54,7 @@ export function Contract(
3154
): (target: Function) => void {
3255
const { name } = options;
3356
return (target: Function): void => {
34-
const existing = mintedNames.get(name);
35-
if (existing && existing !== target) {
36-
throw new Error(
37-
`@Contract name '${name}' is already used by '${
38-
existing.name || "another contract"
39-
}'. Token names must be unique — a duplicate silently aliases two contracts.`,
40-
);
41-
}
42-
mintedNames.set(name, target);
57+
mintTokenName(name, target);
4358
Object.defineProperty(target, CONTRACT_NAME, {
4459
value: name,
4560
writable: false,
@@ -64,7 +79,10 @@ export function getContractName(token: any): string | undefined {
6479
return undefined;
6580
}
6681

67-
/** Test seam — the duplicate-name registry otherwise persists per process. */
82+
/**
83+
* Test seam — the duplicate-name registry (contracts and injection tokens
84+
* alike) otherwise persists per process.
85+
*/
6886
export function clearMintedContractNames(): void {
6987
mintedNames.clear();
7088
}

lib/common/di/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export {
99
clearMintedContractNames,
1010
} from "./contract";
1111
export type { IContractOptions } from "./contract";
12+
export {
13+
InjectionToken,
14+
getInjectionTokenName,
15+
INJECTION_TOKEN_NAME,
16+
} from "./injection-token";
1217
export { provide, provideLazy } from "./providers";
1318
export type {
1419
Provider,

lib/common/di/injection-token.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { mintTokenName } from "./contract";
2+
3+
/**
4+
* Mirrors CONTRACT_NAME's `Symbol.for` reasoning: an extension's duplicated
5+
* copy of this module must read the marker off tokens minted by the running
6+
* copy, which a unique `Symbol()` would hide.
7+
*/
8+
export const INJECTION_TOKEN_NAME = Symbol.for(
9+
"nativescript:di:injectionTokenName",
10+
);
11+
12+
/**
13+
* A typed DI token for a dependency that is not a class — an imported module
14+
* namespace, a plain value, a function. `@Contract` covers services, which
15+
* have an abstract class to decorate; this covers everything else.
16+
*
17+
* The description doubles as the legacy registry name, exactly as a contract's
18+
* name does, so a token is a typed alias over the registration it names:
19+
*
20+
* ```ts
21+
* const XCODE = new InjectionToken<typeof import("nativescript-dev-xcode")>(
22+
* "xcode",
23+
* );
24+
* inject(XCODE); // finds register("xcode", …) untouched
25+
* ```
26+
*/
27+
export class InjectionToken<T = any> {
28+
/**
29+
* Phantom, never assigned: with no member mentioning `T` the type parameter
30+
* is erased and every token becomes assignable to every other one.
31+
*/
32+
declare private readonly resolvedType: T;
33+
34+
/**
35+
* @param description Canonical registry name. A leading `$` is stripped, so
36+
* the token always keys the same record the string spellings do.
37+
*/
38+
constructor(description: string) {
39+
const name = description[0] === "$" ? description.slice(1) : description;
40+
mintTokenName(name, this);
41+
Object.defineProperty(this, INJECTION_TOKEN_NAME, {
42+
value: name,
43+
writable: false,
44+
enumerable: false,
45+
configurable: false,
46+
});
47+
}
48+
49+
public get description(): string {
50+
return (<any>this)[INJECTION_TOKEN_NAME];
51+
}
52+
53+
public toString(): string {
54+
return `InjectionToken(${this.description})`;
55+
}
56+
}
57+
58+
/**
59+
* Reads the constructor-set name. Own-property check, and by marker rather
60+
* than `instanceof`, so tokens minted by a duplicated copy of this module are
61+
* still recognized.
62+
*/
63+
export function getInjectionTokenName(token: any): string | undefined {
64+
if (
65+
token !== null &&
66+
typeof token === "object" &&
67+
Object.prototype.hasOwnProperty.call(token, INJECTION_TOKEN_NAME)
68+
) {
69+
return token[INJECTION_TOKEN_NAME];
70+
}
71+
return undefined;
72+
}

lib/common/di/injector.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { annotate } from "../helpers";
22
import { getContractName } from "./contract";
33
import { resolveForwardRef } from "./forward-ref";
44
import { runInInjectionContext } from "./inject";
5+
import { getInjectionTokenName } from "./injection-token";
56
import type {
67
InternalProvider,
78
Provider,
89
ProviderToken,
910
Type,
1011
} from "./providers";
1112

12-
type TokenKey = string | Function;
13+
type TokenKey = string | object;
1314

1415
export interface InjectOptions {
1516
/** Resolve to null instead of throwing when the token is not registered. */
@@ -241,7 +242,7 @@ export class Injector {
241242
if (typeof token === "string") {
242243
return [normalizeName(token)];
243244
}
244-
const name = getContractName(token);
245+
const name = tokenNameOf(token);
245246
return name !== undefined ? [token, name] : [token];
246247
}
247248

@@ -294,7 +295,7 @@ export class Injector {
294295
if (direct) {
295296
return direct;
296297
}
297-
const name = getContractName(token);
298+
const name = tokenNameOf(token);
298299
return name !== undefined ? this.providers.get(name) : undefined;
299300
}
300301

@@ -412,9 +413,23 @@ function normalizeName(name: string): string {
412413
return name[0] === "$" ? name.slice(1) : name;
413414
}
414415

416+
/** The name a non-string token aliases in the legacy registry, if it has one. */
417+
function tokenNameOf(token: ProviderToken): string | undefined {
418+
const injectionTokenName = getInjectionTokenName(token);
419+
return injectionTokenName !== undefined
420+
? injectionTokenName
421+
: getContractName(token);
422+
}
423+
415424
function displayNameOf(token: ProviderToken): string {
416425
if (typeof token === "string") {
417426
return normalizeName(token);
418427
}
419-
return getContractName(token) || token.name || "<anonymous class>";
428+
const injectionTokenName = getInjectionTokenName(token);
429+
if (injectionTokenName !== undefined) {
430+
return `InjectionToken(${injectionTokenName})`;
431+
}
432+
return (
433+
getContractName(token) || (<Function>token).name || "<anonymous class>"
434+
);
420435
}

lib/common/di/providers.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import type { InjectionToken } from "./injection-token";
2+
13
export type Type<T> = new (...args: any[]) => T;
24
export type AbstractType<T> = abstract new (...args: any[]) => T;
35

4-
export type ProviderToken<T = any> = string | Type<T> | AbstractType<T>;
6+
export type ProviderToken<T = any> =
7+
string | Type<T> | AbstractType<T> | InjectionToken<T>;
58

69
interface IBaseProvider<T> {
710
provide: ProviderToken<T>;
@@ -57,11 +60,11 @@ export type InternalProvider<T = any> = Provider<T> | ILazyRequireProvider;
5760

5861
/** Enforces at compile time that the implementation satisfies the token. */
5962
export const provide = <T>(
60-
token: AbstractType<T> | string,
63+
token: AbstractType<T> | InjectionToken<T> | string,
6164
impl: Type<T>,
6265
): Provider<T> => ({ provide: token, useClass: impl });
6366

6467
export const provideLazy = <T>(
65-
token: AbstractType<T> | string,
68+
token: AbstractType<T> | InjectionToken<T> | string,
6669
load: () => Type<T>,
6770
): Provider<T> => ({ provide: token, useLazyClass: load });

lib/contracts/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export {
1111
CONTRACT_NAME,
1212
} from "../common/di/contract";
1313
export type { IContractOptions } from "../common/di/contract";
14+
export {
15+
InjectionToken,
16+
getInjectionTokenName,
17+
INJECTION_TOKEN_NAME,
18+
} from "../common/di/injection-token";
1419
export { inject, runInInjectionContext } from "../common/di/inject";
1520
export { forwardRef, resolveForwardRef } from "../common/di/forward-ref";
1621
export { Injector } from "../common/di/injector";
@@ -38,6 +43,9 @@ export { ProjectNameService } from "./project-name-service";
3843
export { Prompter } from "./prompter";
3944
export { TempService } from "./temp-service";
4045

46+
export { PBXPROJ_DOM_XCODE } from "./pbxproj-dom-xcode";
47+
export { XCODE } from "./xcode";
48+
4149
export {
4250
defineCommand,
4351
isCommandDefinition,

lib/contracts/pbxproj-dom-xcode.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { InjectionToken } from "../common/di/injection-token";
2+
// Type-only: this entry point must stay side-effect-free, and the token is an
3+
// alias over the registration in lib/node/pbxproj-dom-xcode.ts, not a second
4+
// loader.
5+
import type * as pbxprojDomXcode from "pbxproj-dom/xcode";
6+
7+
/**
8+
* DOM-style reader/writer for Xcode project files.
9+
*/
10+
export const PBXPROJ_DOM_XCODE = new InjectionToken<typeof pbxprojDomXcode>(
11+
"pbxprojDomXcode",
12+
);

lib/contracts/xcode.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { InjectionToken } from "../common/di/injection-token";
2+
// Type-only: this entry point must stay side-effect-free, and the token is an
3+
// alias over the registration in lib/node/xcode.ts, not a second loader.
4+
import type * as xcode from "nativescript-dev-xcode";
5+
6+
/**
7+
* Reads and edits `.pbxproj` files.
8+
*/
9+
export const XCODE = new InjectionToken<typeof xcode>("xcode");

0 commit comments

Comments
 (0)