When working with component-based frameworks, we tend to favor Composition over Inheritance because of the flexibility that Composition provides. This is especially true in Angular due to Dependency Injection and how Inheritance in JavaScript works.
Take a look at the following class:
1@Directive()2export abstract class Instance<TObject> {3 constructor(4 protected serviceOne: ServiceOne,5 protected serviceTwo: ServiceTwo,6 protected tokenOne: TokenOne, // the list can go on and on7 ) {}8}
This is how one would construct an abstract class
in Angular and set up Dependencies for that class. Sub-classes can extends
the base abstract class as follow:
1@Component({2 providers: [3 /* Concrete class can also override the dependencies here if needed */4 ],5})6export class Concrete extends Instance<SomeConcreteEntity> {7 // this.serviceOne is available8 // this.serviceTwo is available9 // this.tokenOne is available10}
Thanks to how Angular resolves Dependency Injection, Concrete
class automatically receives those Injectables assuming that:
Concrete
does not need extra injectablesConcrete
constructor is expected to be the same asInstance
constructor
The above constraints are limiting to what we can do with Instance
’s sub-classes. What if we have extra logic in the sub-class constructor? What if we need to inject more Injectables into the sub-class? Let’s see an example
1@Component()2export class ConcreteTwo extends Instance<SomeConcreteTwoEntity> {3 // Extreme verbose constructor repeating the injectables 🥲4 constructor(5 serviceOne: ServiceOne,6 serviceTwo: ServiceTwo,7 tokenOne: TokenOne,8 private theOneConcreteTwoNeeds: TheOne,9 ) {10 // because we need to call super() and pass those injectables for the base class11 super(serviceOne, serviceTwo, tokenOne);12 }13}
Duplicating code is one thing. One important aspect is that now ConcreteTwo
, or any sub-class of Instance
, needs to adjust its constructor AND tests if Instance
needs more Injectables in the future. That is painful 😖
We can convert the Inheritance here to Composition by bringing the logic in Instance
to a Service instead
1@Injectable()2export class InstanceService {3 constructor /** same DIs as abstract class Instance **/() {}4
5 /* however, InstanceService does not have ngOnInit life-cycle6 * so we need to implement some method to call in the component7 */8 init() {9 // some init logic10 }11}
Then use the InstanceService
as follow
1@Component({2 providers: [InstanceService],3})4export class Concrete {5 constructor(private instanceService: InstanceService) {}6
7 ngOnInit() {8 this.instanceService.init();9 }10}
This way if InstanceService
changes in the future, Concrete
might not need to change its code or tests. However, there are some caveats:
- Services do not have
@Input()
and@Output()
, what if ourInstance
base class has some common inputs and outputs? There are ways to achieve this but it is verbose, error-prone, and repetitive. Eg: We can have Subjects in the Service and use Setter Inputs to push data through those Subjects - Because Services do not have
ngOnInit
life-cycle, ALL components that injectInstanceService
need to manually callinit()
method
Well, Angular 14 comes with a fix that maybe, just maybe, makes Inheritance in Angular viable again
inject()
Here’s the official documentation on inject()
. TL;DR, when we create InjectionToken
, we can define some sort of default value for said InjectionToken
with the following syntax
1export const MY_TOKEN = new InjectionToken("My Token", {2 factory: () => {3 // we can inject Root Injectables here4 const someOtherToken = inject(SOME_OTHER_TOKEN); // assume SOME_OTHER_TOKEN is provided on the Root injector5 return someOtherToken.someProperty;6 },7});
Then we can start using MY_TOKEN
with @Inject(MY_TOKEN)
, if we do not provide MY_TOKEN
anywhere in our Injector tree, then the value we define in factory()
will be used. Then comes this PR: https://github.com/angular/angular/pull/45991
The PR makes it possible to use inject()
in a component’s constructor. One might think “It’s not a big deal” but based on the example above, we see that it is. Let’s rewrite the Instance
base class:
1@Directive()2export abstract class Instance<TObject> {3 protected serviceOne = inject(ServiceOne);4 protected serviceTwo = inject(ServiceTwo);5 protected tokenOne = inject(TokenOne);6}
Then our Concrete
1@Component()2export class Concrete extends Instance<SomeConcreteEntity> {3 // this.serviceOne, this.serviceTwo, this.tokenOne are all available here4
5 constructor(private theOneThatConcreteNeeds: TheOne) {6 super();7 }8}
This fixes our issue above 🤯! What’s more? Instance
can have Inputs, Outputs, and it can have life-cycle hooks like ngOnInit
1@Directive()2export abstract class Instance<TObject> {3 ngOnInit() {4 // do initialization stuffs here. Sub-classes will inherit this5 }6}
I get very excited because I use Inheritance for Angular Three and cannot wait to make this big refactor that reduces the code and complexity by a ton. I hope that you all can find ways to utilize this change to inject()
in Angular 14 as well.
This is not a blog to promote Inheritance over Composition. This is merely stating the use-case that I run into and how Inheritance helps solve it easier than Composition. There might be other caveats about testability with inject()
. If you’re interested and curious, hit me up on Twitter and I am happy to jump in a call and show you the exact use case.
Have fun and good luck!