Playing with Providers
Michael Muscat
Posted on May 17, 2020
Dependency injection is one of Angular's most powerful features. Angular Ivy supercharged this with bloom filters and monomorphism while adding more options for providedIn
to configure the scope of tree-shakable providers.
After digging into Angular, here's 3 things you may not know about providers:
1. inject()
Under the hood Angular uses code reflection to work out what the dependencies of your classes are. Let's look at an example:
@Injectable()
export class ApiService {
constructor(private http: HttpClient) {}
}
All well and good. We know that when Angular compiles this code it will detect the HttpClient
type annotation and wire up ApiService
so that it is provided with an instance of Httpclient
when it is constructed by Angular.
We can write this another way.
export class ApiService {
private http = inject(HttpClient)
}
This code doesn't use Reflection, so we can omit @Injectable()
. We are now imperatively fetching the dependency using the service locator pattern. This means class dependencies are hidden within the closure of the class constructor unless we expose it. We can't replace them through the constructor arguments during testing either, but we can still use TestBed
to configure the injector.
2. Injection Context
But why does this work? The documentation says:
Must be used in the context of a factory function such as one defined for an
InjectionToken
. Throws an error if not called from such a context.
This is interesting, just what exactly qualifies as a factory function? It says we can use it inside InjectionToken
.
const IS_BROWSER = new InjectionToken<boolean>("IS_BROWSER", {
factory() {
const platformId = inject(PLATFORM_ID)
return isPlatformBrowser(platformId)
}
})
Well that's convenient. It turns out we can call inject
inside any injectable function, class, or factory. Thats means everything that goes in the providers
arrays.
const GLOBAL = new InjectionToken<any>("GLOBAL")
const FACTORY_PROVIDER = {
provide: GLOBAL,
useFactory: () => {
return inject(IS_BROWSER) ? window : global
}
}
Whenever Angular injects a value, it uses a context aware injector that knows where it is in the injector hierarchy. We can create this context ourselves.
const spy = fn()
class Context {
constructor() {
spy(inject(INJECTOR))
}
}
const parent = Injector.create({
providers: [Context]
})
const child = Injector.create({
parent,
providers: [Context]
})
child.get(Context)
expect(spy).toHaveBeenCalledWith(child)
child.get(Context, null, InjectFlags.SkipSelf)
expect(spy).toHaveBeenCalledWith(parent)
3. Type Safety
Let's return to the documentation to see what else it says about inject()
.
Within such a factory function, using this function to request injection of a dependency is faster and more type-safe than providing an additional array of dependencies (as has been common with
useFactory
providers).
Faster and more type safe. That's great! It's also much more ergonomic when dealing with factory providers, value providers, or generics.
const API_KEY = new InjectionToken<string>("API_KEY")
@Injectable()
class ApiService {
constructor(@Inject(API_KEY) private apiKey: string) {}
}
This code is not type safe! The constructor is not checked to ensure that string
matches the signature of @Inject(API_KEY)
. It's also verbose.
class ApiService {
private apiKey = inject(API_KEY) // inferred as string
}
For generics we can write a wrapper function.
function injectStore<T>(store = Store): Store<T> {
return inject(store)
}
class NgService {
private store = injectStore<AppState>() // inferred as Store<AppState>
}
Caveats
Only call
inject()
synchronously inside the factory/constructor!
You might be thinking, what about components? Sadly, components, directives and pipes (ie. declarations
) are not created inside an injection context.
@Component()
export class NgComponent {
// Error: inject() must be called from an injection context
private http = inject(HttpClient)
}
There are also issues when trying to inject abstract types, such as ChangeDetectorRef
.
export class NgService {
// Type error, no matching overload
private cdr = inject(ChangeDetectorRef)
}
This actually works at runtime but the type signature only allows concrete classes.
If you're feeling adventurous there is a workaround to both of these issues with the help of private APIs.
import { ɵɵdirectiveInject, Type, AbstractType, InjectionToken, ChangeDetectorRef }
from "@angular/core"
export function inject<T>(
token: Type<T> | AbstractType<T> | InjectionToken<T>,
flags?: InjectFlags,
): T {
return ɵɵdirectiveInject(token as any, flags)
}
@Component()
export class NgComponent {
// it works!
private cdr = inject(ChangeDetectorRef)
}
ɵɵdirectiveInject
is the injector used internally by Angular when compiling your components and directives into defineXXX
factories.
class SomeDirective {
constructor(directive: DirectiveA) {}
static ɵdir = ɵɵdefineDirective({
type: SomeDirective,
factory: () => new SomeDirective(ɵɵdirectiveInject(DirectiveA))
});
}
Does this sort of thing interest you? I'm looking for contributors to help build a Composition API for Angular.
Thanks for reading!
Posted on May 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 20, 2024
November 15, 2024