loading...

Playing with Providers

stupidawesome profile image Michael Muscat ・3 min read

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!

Discussion

pic
Editor guide