Skip to content

Commit 6d4d45e

Browse files
committed
docs: document the token-based injection API
dependency-injection.md covers tokens (@contract), inject()/Injector, provider kinds, resolution semantics, forwardRef, coexistence with the legacy $injector, and a legacy-to-new quick reference. extending-cli.md shows hook authors how to reach typed services via $injector.di.
1 parent 3f4ab6f commit 6d4d45e

2 files changed

Lines changed: 273 additions & 0 deletions

File tree

dependency-injection.md

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
Dependency Injection
2+
====================
3+
4+
The NativeScript CLI is migrating from name-based dependency injection (the
5+
`$injector` global, where a constructor parameter named `$doctorService`
6+
resolves the service registered under the string `"doctorService"`) to a typed,
7+
token-based container. Both APIs are backed by **one container**, so they can be
8+
mixed freely: a service registered under a legacy string name is resolvable
9+
through its typed token and vice versa. The legacy `$injector` surface remains
10+
fully supported, is marked `@deprecated` in-editor, and its usage is traced at
11+
runtime so removal can be staged over releases.
12+
13+
Inside the CLI, import from `lib/common/di`. Extension and hook authors import
14+
the same API from the `nativescript/contracts` subpath (see
15+
[For extension and hook authors](#for-extension-and-hook-authors)).
16+
17+
At a glance
18+
-----------
19+
20+
```ts
21+
import { inject, DoctorService } from "nativescript/contracts";
22+
23+
class PlatformChecker {
24+
private doctorService = inject(DoctorService); // typed, no decorators needed
25+
private logger = inject("logger"); // string tokens still work
26+
27+
async check(projectDir: string): Promise<boolean> {
28+
return this.doctorService.canExecuteLocalBuild({ projectDir });
29+
}
30+
}
31+
```
32+
33+
Tokens: `@Contract`
34+
-------------------
35+
36+
A token is an abstract class annotated with `@Contract`. The class is both the
37+
compile-time type and the runtime lookup key; the decorator records the token's
38+
canonical string name:
39+
40+
```ts
41+
import { Contract } from "nativescript/contracts";
42+
43+
@Contract({ name: "doctorService" }) // canonical form, no `$`
44+
export abstract class DoctorService {
45+
abstract canExecuteLocalBuild(configuration?: {
46+
platform?: string;
47+
projectDir?: string;
48+
}): Promise<boolean>;
49+
}
50+
```
51+
52+
Rules:
53+
54+
- **The name is an explicit string literal** — never derived from `class.name`,
55+
which changes under minification.
56+
- Names are minted at this single choke point: declaring two contracts with the
57+
same name **throws at load time**, because a duplicate would silently alias
58+
two tokens.
59+
- The options object leaves room for future fields without changing call sites.
60+
- Implementations do not become tokens by extending or implementing a contract;
61+
only the decorated class itself is a token.
62+
63+
Resolving: `inject()` and `Injector`
64+
------------------------------------
65+
66+
`inject(token)` returns the singleton for a token from the current injection
67+
context. It is **synchronous by design** and valid only:
68+
69+
- in field initializers,
70+
- in constructor bodies,
71+
- in provider factories,
72+
- inside an explicit `runInInjectionContext(injector, fn)`.
73+
74+
It is **not** valid after an `await`. For late or conditional lookups,
75+
self-inject the `Injector` and use `get()`:
76+
77+
```ts
78+
import { inject, Injector } from "nativescript/contracts";
79+
80+
class EnvironmentChecker {
81+
private injector = inject(Injector);
82+
83+
async check(projectDir: string) {
84+
await somethingAsync();
85+
return this.injector.get(DoctorService); // fine after await
86+
}
87+
}
88+
```
89+
90+
`Injector.get()` accepts a contract class, a string name, or a `$`-prefixed
91+
string name — all three return the same instance.
92+
93+
Registering: providers
94+
----------------------
95+
96+
```ts
97+
import { provide, provideLazy, Injector } from "nativescript/contracts";
98+
99+
const injector = new Injector([
100+
// eager class binding; type-checked: the impl must satisfy the token
101+
provide(DoctorService, DoctorServiceImpl),
102+
103+
// deferred loading: the module is require()d on first resolution only
104+
provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl),
105+
106+
{ provide: "config", useValue: { DISABLE_HOOKS: false } },
107+
{ provide: "dispatcher", useFactory: () => createDispatcher(), shared: false },
108+
]);
109+
110+
// registration is also allowed after construction; re-registering a token
111+
// updates the existing record in place
112+
injector.register(provide(ProjectNameService, ProjectNameServiceImpl));
113+
```
114+
115+
Provider kinds:
116+
117+
| Kind | Shape | Notes |
118+
|---|---|---|
119+
| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields |
120+
| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy |
121+
| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance |
122+
| Factory | `{ provide, useFactory }` | called inside an injection context |
123+
124+
`shared: false` makes a provider transient: every resolution constructs a fresh
125+
instance. Transient instances are still retained by the container so
126+
`dispose()` reaches them.
127+
128+
For per-call construction with overrides (a fresh instance of a class with some
129+
dependencies replaced), use `createInstance`:
130+
131+
```ts
132+
const debugService = injector.createInstance(IOSDeviceDebugService, [
133+
{ provide: "device", useValue: device },
134+
]);
135+
```
136+
137+
Overrides shadow **one level deep only** — the direct dependencies of the class
138+
being constructed. Nested dependencies are constructed by the injector that
139+
owns them and never see the per-call providers.
140+
141+
Resolution semantics
142+
--------------------
143+
144+
- Lookup is **class object first, token name on a miss**, checked per injector
145+
level before delegating to the parent. Both keys index the same provider
146+
record, so re-registering a service by its string name (as plugins are
147+
documented to do with `$logger`) stays visible to `inject(Logger)` consumers.
148+
- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")`
149+
are the same registration.
150+
- The name fallback also makes **duplicated contract copies interchangeable**:
151+
if an extension's dependency tree carries its own copy of a contract class,
152+
that copy resolves to the same provider by name. "Works locally, breaks when
153+
installed" is not a failure mode of this design.
154+
- Cyclic dependencies fail with the full resolution path
155+
(`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`).
156+
157+
Child scopes
158+
------------
159+
160+
`injector.createChild(providers)` creates a scope that shadows its parent for
161+
the given tokens and falls through for everything else. Sibling scopes are
162+
isolated. Scopes are how per-invocation data (hook payloads, per-call
163+
overrides) is layered over the shared singletons without ever entering the
164+
root container.
165+
166+
`forwardRef`
167+
------------
168+
169+
Provider arrays are evaluated at module load. When a token is declared later in
170+
the same file (TDZ) or reached through a circular import, wrap the reference in
171+
a thunk; it is read only when the injector processes the provider:
172+
173+
```ts
174+
import { forwardRef } from "nativescript/contracts";
175+
176+
const providers = [
177+
{ provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl },
178+
];
179+
```
180+
181+
`forwardRef` defers *references*, not construction — it cannot break an
182+
instantiation cycle between two services. For that, self-inject the `Injector`
183+
and resolve late (see above).
184+
185+
Working alongside the legacy `$injector`
186+
----------------------------------------
187+
188+
The `Yok` facade (`global.$injector`) delegates to the token-based container,
189+
exposed as `$injector.di`. Everything is one registry:
190+
191+
```ts
192+
$injector.resolve("doctorService") === $injector.di.get(DoctorService); // true
193+
```
194+
195+
- Legacy string names are permanent: a contract's token name is its interop
196+
identity, used by hooks, plugins, and the public API. Nothing is deleted
197+
per-service.
198+
- Every legacy member (`resolve`, `register`, `require*`, the command-registry
199+
surface) carries `@deprecated` JSDoc naming its replacement.
200+
- Legacy usage at the external entry points (param-name hooks, require-time
201+
extension registration, help templating) is reported through a deprecation
202+
tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or
203+
`NS_DEPRECATIONS=error` to preview the stricter stages that later releases
204+
will default to.
205+
206+
For extension and hook authors
207+
------------------------------
208+
209+
Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency`
210+
for local development) and import from the `contracts` subpath:
211+
212+
```ts
213+
import { inject, DoctorService } from "nativescript/contracts";
214+
```
215+
216+
- The subpath resolves through a directory `package.json` — the CLI's
217+
`package.json` deliberately has **no `exports` map**, so any deep `require()`
218+
paths you already use keep working.
219+
- The entry point is side-effect-free: importing it never boots a CLI runtime,
220+
even from a duplicated copy in your dependency tree.
221+
- The existing `$injector`-based extension and hook mechanisms keep working
222+
unchanged; the typed API is additive.
223+
224+
Available contracts
225+
-------------------
226+
227+
The first tranche, growing as services migrate:
228+
229+
| Token | Legacy name |
230+
|---|---|
231+
| `DoctorService` | `doctorService` |
232+
| `ProjectNameService` | `projectNameService` |
233+
234+
Legacy → new quick reference
235+
----------------------------
236+
237+
`di` below is the token-based container backing the facade (`$injector.di`),
238+
or any `Injector` you hold directly.
239+
240+
| Legacy (`$injector`) | New |
241+
|---|---|
242+
| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` |
243+
| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` |
244+
| `register("name", Impl)` | `di.register(provide(Token, Impl))` |
245+
| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` |
246+
| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` |
247+
| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` |
248+
| constructor param `$name` | `inject(Token)` field initializer |

extending-cli.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,31 @@ If these two members are not set, the CLI prints the returned error colored as f
9898
9999
Furthermore, the global variable `$injector` of type `IInjector` provides access to the CLI Dependency Injector, through which all code services are available.
100100
101+
Using the Typed Injection API
102+
==============================
103+
104+
The CLI also ships a typed, token-based injection API from the `nativescript/contracts` subpath, backed by the same container as `$injector` — a service resolved through a token is the identical instance you get by parameter name. See [dependency-injection.md](dependency-injection.md) for the full API.
105+
106+
From a hook, access it through the injector's `di` container:
107+
108+
```JavaScript
109+
const { DoctorService } = require("nativescript/contracts");
110+
111+
module.exports = function ($injector, $projectData) {
112+
const doctorService = $injector.di.get(DoctorService);
113+
return doctorService.canExecuteLocalBuild({
114+
projectDir: $projectData.projectDir,
115+
});
116+
};
117+
```
118+
119+
Notes:
120+
121+
* Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service.
122+
* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { DoctorService } from "nativescript/contracts"`.
123+
* `inject()` itself requires an injection context, which the current hook invocation does not provide — hooks should use `$injector.di.get(...)` as above. A typed hook API that removes the parameter-name convention entirely is planned; until then, parameter-name injection remains fully supported, though its usage is traced for eventual deprecation (run the CLI with `--log trace` to see the reports, or set `NS_DEPRECATIONS=warn` to surface them).
124+
* Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); every other service remains reachable by name — `$injector.di.get("logger")` and `$injector.resolve("logger")` are equivalent.
125+
101126
Commands with Hooking Support
102127
==============================
103128

0 commit comments

Comments
 (0)