All Blogs

Cleaner Abstract Constructors in Angular 14

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()
2
export abstract class Instance<TObject> {
3
constructor(
4
protected serviceOne: ServiceOne,
5
protected serviceTwo: ServiceTwo,
6
protected tokenOne: TokenOne, // the list can go on and on
7
) {}
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
})
6
export class Concrete extends Instance<SomeConcreteEntity> {
7
// this.serviceOne is available
8
// this.serviceTwo is available
9
// this.tokenOne is available
10
}

Thanks to how Angular resolves Dependency Injection, Concrete class automatically receives those Injectables assuming that:

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()
2
export 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 class
11
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()
2
export class InstanceService {
3
constructor /** same DIs as abstract class Instance **/() {}
4
5
/* however, InstanceService does not have ngOnInit life-cycle
6
* so we need to implement some method to call in the component
7
*/
8
init() {
9
// some init logic
10
}
11
}

Then use the InstanceService as follow

1
@Component({
2
providers: [InstanceService],
3
})
4
export 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:

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

1
export const MY_TOKEN = new InjectionToken("My Token", {
2
factory: () => {
3
// we can inject Root Injectables here
4
const someOtherToken = inject(SOME_OTHER_TOKEN); // assume SOME_OTHER_TOKEN is provided on the Root injector
5
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()
2
export 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()
2
export class Concrete extends Instance<SomeConcreteEntity> {
3
// this.serviceOne, this.serviceTwo, this.tokenOne are all available here
4
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()
2
export abstract class Instance<TObject> {
3
ngOnInit() {
4
// do initialization stuffs here. Sub-classes will inherit this
5
}
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!

Published on Tue May 17 2022


Angular