Using Abstract Classes As Dependency-Injection Tokens With "providedIn" Semantics In Angular 9.1.9
Earlier this week, I looked at saving temporary form-data to the SessionStorage
API in Angular 9 so that a user wouldn't lose their form-data if they accidentally refreshed the page. In that exploration, I had a Storage class that used the SessionStorage
API internally in a hard-coded fashion. But, I wanted to take a look at how I could make that behavior more dynamic using Dependency-Injection (DI) tokens. A few years ago, I wrote about using abstract classes as DI tokens in Angular 4; however, with the new providedIn
@Injectable
decorator, I didn't know how to do this. That is, until I came across a post on tree-shakeable providers by Manfred Steyer. What follows in my attempt to take Manfred's insights and translate them over to my temporary storage exploration in Angular 9.1.9.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
A couple of versions ago, Angular introduced a new @Injectable()
decorator semantic that would allow us to "provide" Services within our Angular application without having to explicitly define those services in the @NgModule
decorators:
@Injectable({
providedIn: "root"
})
export class MyServiceClass {
// ....
}
I couldn't really tell you what this providedIn:"root"
concept is doing mechanically; I can only tell you that it magically adds the given Service to the Dependency-Injection (DI) container. This allows other classes to then inject this Service using the traditional type-based annotations:
import { MyServiceClass } from "./my-service";
@Injectable({
providedIn: "root"
})
export class SomeConsumerClass {
constructor( myService: MyServiceClass ) {
// ....
}
}
Now, getting back to the idea of using an abstract class as a dependency-injection token, I wasn't sure how to turn the MyServiceClass
reference into an abstract
class that concrete implementations could implement. I figured that I could always go and add the default, concrete implementation to the @NgModule
decorator; however, that would defeat the ease-of-use that the providedIn
syntax is bringing to the table.
It wasn't until I read Manfred's blog post that I even realized that the @Injectable()
decorator has more than just the providedIn
key. In fact, it allows for all the same "use" options that we can have in the @NgModule
providers:
useClass
useValue
useFactory
useExisting
ASIDE: The documentation for the
@Injectable()
decorator listsprovidedIn
as the only option, which is definitely part of my confusion on the matter.
With this new insight, we can expose an abstract class as a dependency-injection token and then use the useClass
option to tell it which concrete implementation to use as the default provider.
Circling back to my temporary storage demo, I can now create a TemporaryStorageService
class that is abstract
, provides a default, concrete implementation, and acts as a dependency-injection token in the Angular application:
// Import the core angular services.
import { forwardRef } from "@angular/core";
import { Injectable } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// By using an ABSTRACT CLASS as the dependency-injection (DI) token, it allows us to
// use the class as both the token and as an INTERFACE that the concrete classes have to
// implement. And, by including the "useClass" property in our decorator, it allows us
// to define the DEFAULT IMPLEMENTATION to be used with this injection token.
@Injectable({
providedIn: "root",
useClass: forwardRef( () => SessionStorageService ) // Default implementation.
})
export abstract class TemporaryStorageService {
public abstract get<T>( key: string ) : Promise<T | null>;
public abstract remove( key: string ) : void;
public abstract set( key: string, value: any ) : void;
}
Here, we're using the abstract class, TemporaryStorageService
, as both the DI token and the Interface for the concrete implementations. We're then using the useClass
option to tell the Angular Injector to provide the SessionStorageService
class as the default implementation for said DI token.
NOTE: I'm using the
forwardRef()
function here because theSessionStorageService
class is defined lower-down in the code-file. I could have swapped the order of the definitions and used the class reference directly; but, I wanted to the code-file to read top-to-bottom.
Now, from my other Services and Components, I can inject the TemporaryStorageService
token and remain blissfully ignorant of whichever implementation is actually being provided. To see this in action, I've created an App component that injects the TemporaryStorageService
token and receives one of the following three concrete implementations:
SessionStorageService
LocalStorageService
InMemoryStorageService
The provided implementation is being driven by the a URL search parameter on page-refresh:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { TemporaryStorageService } from "./temporary-storage.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
template:
`
<ul>
<li>
<a href="./index.html?which=SessionStorageService">
Use <code>SessionStorageService</code>
</a>
</li>
<li>
<a href="./index.html?which=LocalStorageService">
Use <code>LocalStorageService</code>
</a>
</li>
<li>
<a href="./index.html?which=InMemoryStorageService">
Use <code>InMemoryStorageService</code>
</a>
</li>
</ul>
`
})
export class AppComponent {
// I initialize the app component.
constructor( temporaryStorage: TemporaryStorageService ) {
// With dependency-injection (DI), all we're doing is asking the DI container
// for a class that implements the "TYPE" of TemporaryStorageService. The
// implementation of said type is of little concern. Let's look at which
// implementation was injection into this component.
console.group( "Injected Storage Service" );
console.log( temporaryStorage );
console.groupEnd();
// Let's test the implementation to make sure it handles the Set / Get workflow.
(async function() {
var key = "Hello";
var value = "World";
temporaryStorage.set( key, value );
console.group( "Testing Set / Get" );
console.log( "Set:", `${ key } -> ${ value }` );
console.log( "Get:", await temporaryStorage.get( key ) );
console.groupEnd();
})();
}
}
As you can see, this App components provides three links which refresh the page with a new ?which
URL search parameter. For the sake of the demo, I'm parsing the URL and defining the DI token overrides in my App module. In the following code, take note that I'm not doing anything for the default implementation, SessionStorageService
- I'm only providing an override for the other two, non-default implementations:
// Import the core angular services.
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { Provider } from "@angular/core";
// Import the application components and services.
import { AppComponent } from "./app.component";
import { InMemoryStorageService } from "./temporary-storage.service";
import { LocalStorageService } from "./temporary-storage.service";
import { TemporaryStorageService } from "./temporary-storage.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
var providers: Provider[] = [];
console.group( "Parsing Provider From URL" );
// Parse the "which={type}" out of the search string.
switch ( getWhichParamFromUrl() ) {
case "SessionStorageService":
console.log( "Found:", "SessionStorageService" );
// This is the default implementation, don't do anything.
break;
case "LocalStorageService":
console.log( "Found:", "LocalStorageService" );
// For the LocalStorage implementation, all we have to do is tell Angular
// Injector to use the given class (LocalStorageService) when one of the other
// classes requests the "TemporaryStorageService" injection token.
providers.push({
provide: TemporaryStorageService,
useClass: LocalStorageService
});
break;
case "InMemoryStorageService":
console.log( "Found:", "InMemoryStorageService" );
// For the In-Memory implementation, all we have to do is tell Angular Injector
// to use the given class (InMemoryStorageService) when one of the other classes
// requests the "TemporaryStorageService" injection token.
providers.push({
provide: TemporaryStorageService,
useClass: InMemoryStorageService
});
break;
}
console.groupEnd();
@NgModule({
imports: [
BrowserModule
],
providers: providers,
declarations: [
AppComponent
],
bootstrap: [
AppComponent
]
})
export class AppModule {
// ...
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// I parse the "which" parameter from the URL (for the demo, not super robust).
function getWhichParamFromUrl() : string {
var matches = window.location.search.match( /((?<=\bwhich=)[^&]+)/i );
return( ( matches && matches[ 0 ] ) || "SessionStorage" );
}
As you can see, for two of the three URL parameters, I'm defining an override to the TemporaryStorageService
DI token (and abstract class). And, if we run this Angular app in the browser and click through the links, we get the following console log output:
How cool is that?! By using an abstract class as the dependency-injection token in conjunction with the useClass
@Injectable()
option, we're able to keep the simplicity of the providedIn
syntax while also allowing for the traditional override functionality of Providers. It's the best of both worlds!
Here's the full code for my TemporaryStorageService
code-file. It includes the abstract class as well as the three concrete implementations:
// Import the core angular services.
import { forwardRef } from "@angular/core";
import { Injectable } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// By using an ABSTRACT CLASS as the dependency-injection (DI) token, it allows us to
// use the class as both the token and as an INTERFACE that the concrete classes have to
// implement. And, by including the "useClass" property in our decorator, it allows us
// to define the DEFAULT IMPLEMENTATION to be used with this injection token.
@Injectable({
providedIn: "root",
useClass: forwardRef( () => SessionStorageService ) // Default implementation.
})
export abstract class TemporaryStorageService {
public abstract get<T>( key: string ) : Promise<T | null>;
public abstract remove( key: string ) : void;
public abstract set( key: string, value: any ) : void;
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface StorageCache {
[ key: string ]: any;
}
@Injectable({
providedIn: "root"
})
export class SessionStorageService implements TemporaryStorageService {
private cache: StorageCache;
private storageKey: string;
// I initialize the session-storage service.
constructor() {
this.cache = Object.create( null );
this.storageKey = "temporary_storage";
}
// ---
// PUBLIC METHODS.
// ---
// I get the value stored at the given key; or null if undefined.
public async get<T>( key: string ) : Promise<T | null> {
return( <T>this.cache[ key ] ?? null );
}
// I remove the value stored at the given key.
public remove( key: string ) : void {
if ( key in this.cache ) {
delete( this.cache[ key ] );
this.persistCache();
}
}
// I store the given value with the given key.
public set( key: string, value: any ) : void {
this.cache[ key ] = value;
this.persistCache();
}
// ---
// PRIVATE METHODS.
// ---
// I load the in-memory cache from the SessionStorage API.
private loadCache() : void {
var serializedData = window.sessionStorage.getItem( this.storageKey );
if ( serializedData ) {
Object.assign( this.cache, JSON.parse( serializedData ) );
}
}
// I persist the in-memory cache to the SessionStorage API.
private persistCache() : void {
// TODO: Wrap this in a debounced-timer so that we're not constantly flushing the
// in-memory cache to the SYNCHRONOUS SessionStorage API. But, this is beyond the
// scope and goal of this demo.
window.sessionStorage.setItem( this.storageKey, JSON.stringify( this.cache ) );
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Injectable({
providedIn: "root"
})
export class LocalStorageService implements TemporaryStorageService {
private cache: StorageCache;
private storageKey: string;
// I initialize the local-storage service.
constructor() {
this.cache = Object.create( null );
this.storageKey = "temporary_storage";
}
// ---
// PUBLIC METHODS.
// ---
// I get the value stored at the given key; or null if undefined.
public async get<T>( key: string ) : Promise<T | null> {
return( <T>this.cache[ key ] ?? null );
}
// I remove the value stored at the given key.
public remove( key: string ) : void {
if ( key in this.cache ) {
delete( this.cache[ key ] );
this.persistCache();
}
}
// I store the given value with the given key.
public set( key: string, value: any ) : void {
this.cache[ key ] = value;
this.persistCache();
}
// ---
// PRIVATE METHODS.
// ---
// I load the in-memory cache from the LocalStorage API.
private loadCache() : void {
var serializedData = window.localStorage.getItem( this.storageKey );
if ( serializedData ) {
Object.assign( this.cache, JSON.parse( serializedData ) );
}
}
// I persist the in-memory cache to the LocalStorage API.
private persistCache() : void {
// TODO: Wrap this in a debounced-timer so that we're not constantly flushing the
// in-memory cache to the SYNCHRONOUS LocalStorage API. But, this is beyond the
// scope and goal of this demo.
window.localStorage.setItem( this.storageKey, JSON.stringify( this.cache ) );
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Injectable({
providedIn: "root"
})
export class InMemoryStorageService implements TemporaryStorageService {
private cache: StorageCache;
// I initialize the in-memory storage service.
constructor() {
this.cache = Object.create( null );
}
// ---
// PUBLIC METHODS.
// ---
// I get the value stored at the given key; or null if undefined.
public async get<T>( key: string ) : Promise<T | null> {
return( <T>this.cache[ key ] ?? null );
}
// I remove the value stored at the given key.
public remove( key: string ) : void {
delete( this.cache[ key ] );
}
// I store the given value with the given key.
public set( key: string, value: any ) : void {
this.cache[ key ] = value;
}
}
Dependency-injection (DI) is simply magical. I suspect it's a huge part of why people who start using Angular can't imagine switching over to another web-application framework. And, I'm loving that the new(ish) providedIn
syntax provides a simple syntax while also allowing for dynamic overrides. Angular is the bee's knees!
ErrorHandler
Service in Angular
Epilogue on the core The Angular framework provides an ErrorHandler
class that is used to log errors to the console (by default). This class can be injected into your services and components using a default implementation; but, it can also be overridden using a Provider, much like we did in this blog post.
However, when you look at the ErrorHandler
code on GitHub, what you will see is that they do not use an abstract class. Instead, they side-step all of the pitfalls of "using a Class as an Interface" by marking everything but the public methods as /** @internal */
.
I don't really understand what this @internal
thing is doing; but, it appears to remove the annotated methods and properties from appearing in the Type Definition file. As such, when you - as a developer - go to author a class that implements ErrorHandler
, you don't also have to implement all of the private methods and properties that Angular defined internally.
I don't have much to say about that - I don't even know if @internal
is annotation that we can use in our own applications. I just thought it was an interesting bit of information.
Want to use code from this post? Check out the license.
Reader Comments
Hi Ben, interesting post! I was just playing around with the same idea.
One question though: is it actually necessary to add the @Injectable decorator to the specific classes like InMemoryStorageService?
@Kevin,
That's a great question. I'm actually not sure off-hand. I'll have to try that out.
Thanks Ben, your article saved my day.
It is very clear and straight to the point. I enjoyed it.
I got a good understanding of the topic in addition to the fact that I am fairly new to the Angular world.
@Nicola,
Welcome to the Angular world! I hope you are enjoying it 😁 Glad this was able to help you out in some way. Dependency Injection (DI) and the "Inversion of Control" (IoC) is basically the bedrock of clean coding practices. That's part of what make Angular so powerful.
snippet-3.ts has no import for SessionStorageService
did you mean TemporaryStorageService
@Ken,
Check later down in the article - the snippet just has a portion of the code; but, there is a full example down at the bottom of the article that (hopefully) ties it together.
okay I get it sorry didnt read entire post
thanks for all this
@Ken,
No worries :)