Skip to content

Latest commit

 

History

History
356 lines (280 loc) · 14.1 KB

File metadata and controls

356 lines (280 loc) · 14.1 KB

Dependency Injection

The NativeScript CLI is migrating from name-based dependency injection (the $injector global, where a constructor parameter named $doctorService resolves the service registered under the string "doctorService") to a typed, token-based container. Both APIs are backed by one container, so they can be mixed freely: a service registered under a legacy string name is resolvable through its typed token and vice versa. The legacy $injector surface remains fully supported, is marked @deprecated in-editor, and its usage is traced at runtime so removal can be staged over releases.

Inside the CLI, import from lib/common/di. Extension and hook authors import the same API from the nativescript/contracts subpath (see For extension and hook authors).

At a glance

import { inject, DoctorService } from "nativescript/contracts";

class PlatformChecker {
	private doctorService = inject(DoctorService); // typed, no decorators needed

	async check(projectDir: string): Promise<boolean> {
		return this.doctorService.canExecuteLocalBuild({ projectDir });
	}
}

Services that have no typed token yet remain reachable by their registry name — inject("logger") — but that is the migration bridge, not the API: prefer the token wherever one exists, and mint a token rather than a new string name.

Tokens: @Contract

A token is an abstract class annotated with @Contract. The class is both the compile-time type and the runtime lookup key; the decorator records the token's canonical string name:

import { Contract } from "nativescript/contracts";

@Contract({ name: "doctorService" }) // canonical form, no `$`
export abstract class DoctorService {
	abstract canExecuteLocalBuild(configuration?: {
		platform?: string;
		projectDir?: string;
	}): Promise<boolean>;
}

Rules:

  • The name is an explicit string literal — never derived from class.name, which changes under minification.
  • Names are minted at this single choke point: declaring two contracts with the same name throws at load time, because a duplicate would silently alias two tokens.
  • The options object leaves room for future fields without changing call sites.
  • Implementations do not become tokens by extending or implementing a contract; only the decorated class itself is a token.

Tokens for non-classes: InjectionToken

Some registrations have no class to decorate — an imported module namespace, a plain value, a function. InjectionToken is the typed key for those:

import { InjectionToken, inject } from "nativescript/contracts";

export const XCODE = new InjectionToken<typeof import("nativescript-dev-xcode")>(
	"xcode",
);

class ProjectPatcher {
	private xcode = inject(XCODE); // typeof import("nativescript-dev-xcode")
}

The description is the registry name, exactly as a contract's name is, so a token is a typed alias onto an existing registration and nothing has to change where the value is registered — injector.register("xcode", xcode) keeps serving inject(XCODE). A leading $ in the description is stripped.

Names are minted in the same registry @Contract uses, so a token and a contract cannot claim the same name: the second one throws at load time, rather than silently aliasing one registration under two tokens.

Tokens are used anywhere a contract class is — inject(), get(), provide() and the provider literals:

{ provide: XCODE, useValue: xcode }
{ provide: XCODE, useFactory: () => require("nativescript-dev-xcode") }

Prefer a @Contract class when the dependency is a service: it also carries the service's shape. Reach for InjectionToken only when there is nothing to decorate.

Resolving: inject() and Injector

inject(token) returns the singleton for a token from the current injection context. It is synchronous by design and valid only:

  • in field initializers,
  • in constructor bodies,
  • in provider factories,
  • inside an explicit runInInjectionContext(injector, fn).

It is not valid after an await. For late or conditional lookups, self-inject the Injector and use get():

import { inject, Injector } from "nativescript/contracts";

class EnvironmentChecker {
	private injector = inject(Injector);

	async check(projectDir: string) {
		await somethingAsync();
		return this.injector.get(DoctorService); // fine after await
	}
}

Injector.get() accepts a contract class, an InjectionToken, a string name, or a $-prefixed string name — all of them return the same instance. The string forms exist for interoperability with the legacy registry; use the token whenever one exists.

Both inject() and get() take Angular-shaped options as their second argument:

inject(DoctorService, { optional: true }); // DoctorService | null — no throw
inject("logger", { skipSelf: true });      // start at the parent: escapes a
                                           // child scope's shadowing entry
inject("options", { self: true });         // this level only — no fallthrough

optional covers not-found only; a found-but-misconfigured provider still throws. self and skipSelf cannot be combined. There is deliberately no host option: it is an Angular component-tree concept with no analog in the CLI's injector hierarchy.

The injection context is shared process-wide. If a hook or extension module ends up resolving a duplicated copy of the CLI (a nested nativescript install, or a project-local copy under a globally-run CLI), its inject() still resolves against the running CLI's context — with a one-time warning, because a duplicated copy loads the CLI twice. Declaring nativescript as a peerDependency lets the running copy be shared instead.

Registering: providers

import { provide, provideLazy, Injector } from "nativescript/contracts";

const injector = new Injector([
	// eager class binding; type-checked: the impl must satisfy the token
	provide(DoctorService, DoctorServiceImpl),

	// deferred loading: the module is require()d on first resolution only
	provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl),

	{ provide: Config, useValue: { DISABLE_HOOKS: false } },
	{ provide: Dispatcher, useFactory: () => createDispatcher(), shared: false },
]);

// registration is also allowed after construction; re-registering a token
// updates the existing record in place
injector.register(provide(ProjectNameService, ProjectNameServiceImpl));

Provider kinds:

Kind Shape Notes
Class provide(Token, Impl) / { provide, useClass } constructed with new Impl() inside an injection context, so inject() works in its fields
Lazy class provideLazy(Token, () => Impl) / { provide, useLazyClass } loader runs on first get() only — keeps startup lazy
Value { provide, useValue } registered instance; re-registering replaces the cached instance. With shared: false there is no resolver and get() throws — a preserved legacy quirk
Factory { provide, useFactory } called inside an injection context

shared: false makes a provider transient: every resolution constructs a fresh instance. Transient instances are still retained by the container so dispose() reaches them.

String keys are accepted anywhere a token is ({ provide: "logger", useValue }) — that is how the legacy facade registers, and how per-call overrides address not-yet-migrated dependencies. New registrations should mint a @Contract class, or an InjectionToken when there is no class to decorate, instead of a new string name.

For per-call construction with overrides (a fresh instance of a class with some dependencies replaced), use createInstance:

const debugService = injector.createInstance(IOSDeviceDebugService, [
	{ provide: "device", useValue: device },
]);

Overrides shadow one level deep only — the direct dependencies of the class being constructed. Nested dependencies are constructed by the injector that owns them and never see the per-call providers.

Resolution semantics

  • Lookup is token identity first, token name on a miss, checked per injector level before delegating to the parent. Both keys index the same provider record, so re-registering a service by its string name (as plugins are documented to do with $logger) stays visible to inject(Logger) consumers. This holds for @Contract classes and InjectionTokens alike.
  • A leading $ is stripped from string tokens: get("$fs") and get("fs") are the same registration.
  • The name fallback also makes duplicated token copies interchangeable: if an extension's dependency tree carries its own copy of a contract class or injection token, that copy resolves to the same provider by name. "Works locally, breaks when installed" is not a failure mode of this design.
  • Cyclic dependencies fail with the full resolution path (Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a).

Child scopes

injector.createChild(providers) creates a scope that shadows its parent for the given tokens and falls through for everything else. Sibling scopes are isolated. Scopes are how per-invocation data (hook payloads, per-call overrides) is layered over the shared singletons without ever entering the root container.

forwardRef

Provider arrays are evaluated at module load. When a token is declared later in the same file (TDZ) or reached through a circular import, wrap the reference in a thunk; it is read only when the injector processes the provider:

import { forwardRef } from "nativescript/contracts";

const providers = [
	{ provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl },
];

forwardRef defers references, not construction — it cannot break an instantiation cycle between two services. For that, self-inject the Injector and resolve late (see above).

Working alongside the legacy $injector

The Yok facade (global.$injector) IS an Injector — the class extends the token-based container — so the new API works on it directly:

$injector.resolve("doctorService") === $injector.get(DoctorService); // true
$injector.register(provide(DoctorService, DoctorServiceImpl));
runInInjectionContext($injector, () => inject(DoctorService));
  • Legacy string names are permanent: a contract's token name is its interop identity, used by hooks, plugins, and the public API. Nothing is deleted per-service.
  • Every legacy member (resolve, register, require*, the command-registry surface) carries @deprecated JSDoc naming its replacement.
  • Legacy usage at the external entry points (param-name hooks, require-time extension registration, help templating) is reported through a deprecation tracer. It logs at trace level today; set NS_DEPRECATIONS=warn or NS_DEPRECATIONS=error to preview the stricter stages that later releases will default to.

For extension and hook authors

Depend on nativescript itself (as a peerDependency, plus a devDependency for local development) and import from the contracts subpath:

import { inject, DoctorService } from "nativescript/contracts";
  • The subpath resolves through a directory package.json — the CLI's package.json deliberately has no exports map, so any deep require() paths you already use keep working.
  • The entry point is side-effect-free: importing it never boots a CLI runtime, even from a duplicated copy in your dependency tree.
  • The existing $injector-based extension and hook mechanisms keep working unchanged; the typed API is additive.

Available contracts

Growing as services migrate. Every token below is a typed alias onto the registration it names — resolving by token and resolving by the legacy name return the same instance.

The contract is also the single source of truth for the service's shape: the ambient interface the CLI has always published extends it (interface ILogger extends Logger {}), so the two cannot drift apart. Add a member to the contract and every existing caller sees it.

Token Legacy name
ChildProcess childProcess
DevicesService devicesService
DoctorService doctorService
Errors errors
FileSystem fs
HostInfo hostInfo
HttpClient httpClient
Logger logger
PackageManager packageManager
ProjectData projectData
ProjectDataService projectDataService
ProjectNameService projectNameService
Prompter prompter
TempService tempService

And the injection tokens, for registrations that are not classes:

Token Legacy name Value
XCODE xcode the nativescript-dev-xcode module
PBXPROJ_DOM_XCODE pbxprojDomXcode the pbxproj-dom/xcode module

Related guides

Legacy → new quick reference

di below is any Injector you hold — including $injector itself, which extends Injector.

Legacy ($injector) New
resolve("name") inject(Token) in an injection context, or di.get(Token)
resolve(SomeClass) / resolve(SomeClass, { dep }) di.createInstance(SomeClass, [{ provide: "dep", useValue }])
register("name", Impl) di.register(provide(Token, Impl))
register("name", instance) di.register({ provide: Token, useValue: instance })
register("name", Impl, false) di.register({ provide: Token, useClass: Impl, shared: false })
require("name", "./path") provideLazy(Token, () => require("./path").Impl)
constructor param $name inject(Token) field initializer