How OOP is mistreated in Angular
Object oriented programming - a word combination instantly recognized by almost every software developer in the world; even the ones who don't use (or even hate) it.

Object oriented programming - a word combination instantly recognized by almost every software developer in the world; even the ones who don't use (or even hate) it. OOP is the most popular and widely used programming paradigm in the world; and the web frontend is no exception to this: React (still) has class-based components; Vue components are objects themselves; but the framework that makes the most use of OOP is, beyond doubt, Angular. Angular uses OOP for lots of its stuff; most of its features are based on classes (components, directives, pipes...), it uses approaches like dependency injection and such, and so on. So naturally, Angular is also the place where OOP gets mistreated the most. In this article, we are going to explore how sometimes people misuse OOP practices in Angular projects, and how we can do better.
Please think about mixins
Mixins are a feature of JavaScript (an, by extension, TypeScript) that allow us to write functions that receive a class (not an instance of a class, but the class itself), extend from it, and return the resulting class. This resulting class then can be used to extend another one of our classes, say a component. Here is an example of a mixin that creates a destroy$
Subject
which then can be used to dispose Observable
streams upon ngOnDestroy
, so we don't have to write it manually every time:
function WithDestroy(Base) {
return class extends Base implements OnDestroy {
destroy$ = new Subject();
ngOnDestroy() {
super.ngOnDestroy();
this.destroy();
}
};
}
If we then extend some of our components from this mixin, we can use the destroy$
Subject
with the takeUntil
operator to unsubscribe automatically, and we won;t even have to implement the ngOnDestroy
method.
If we then extend some of our components from this mixin, we can use the destroy$ Subject with the takeUntil
operator to unsubscribe automatically, and we won’t even have to implement the ngOnDestroy
method.
This works in a way as if we have a Base class that implements the ngOnDestroy
method, and we extend our class that needs unsubscription logic from it. But because it is a function that accepts a class as an argument and extends from it, we can still extend our class from another one while using WithDestroy, creating some sort of multiple inheritance.
export class MyComponent extends WithDestroy(SomeOtherComponent) {
constructor(private service: DataService) {
super();
}
ngOnInit() {
this.subscription = this.service.selectSomeData().pipe(
takeUntil(this.destroy$), // we can use this from the mixin class
).subscribe(
// handle data
);
}
}
Mixins are a nice way to share functionality between classes without having to resort to classic inheritance or breaking inheritance chains. You can read more about mixins in my article Harnessing the power of Mixins in Angular.
Inheritance is not a toy
Inheritance, or the practice of extending one class from another, is the most well known (and probably the simplest) practice in OOP. But it gets some notoriety because of how it is being used in places it is not needed or applicable.
Inheritance is an is-a relationship, meaning if we extend class A from class B, we should be able to say A is a B in a way that would make sense. For example, if we extend class Car from class Vehicle, we can safely say Car is a Vehicle.
But sometimes relationships exist between classes which can be best described as a has-a relationship. For example, a Car has an Engine, but a Car is not an Engine, so it won't make sense to extend class Car from class Engine, despite the possibility that Car might access some methods and properties of an Engine.
Now, this does not make sense, because a Car merely has an engine, but does not have the properties of an Engine. Instead, we should use object composition and incorporate Engine as a nested property of a Car.
What gets messed up a lot is the fact that some developers might think "well, I need method M from class B in class A, so I should extend A from B"; this, in 99 percent of cases, is a trap.
In those cases, it is useful to reevaluate the relationship and find out if the relationship is an is-like-a relationship. In our previously defined example the relationship was clearly not like that (a Car is not like an Engine). But in case of Angular services there might be cases when this can be applicable. For example, imagine a wrapper around HttpClient
, and a data fetching service like ProductService
. Clearly a data handling service is not a generic wrapper around HttpClient
, it behaves like one, so inheritance can be considered in this scenario. It is important to be careful, and remember about DI and where it is more applicable than inheritance.
The main purpose of inheritance is not sharing functionality, but for representing relationships between data structures. But inheritance can be used in an is-like-a relationship scenario.
If we want to share functionality of class A in class B without inheritance, Angular has a remedy for that: dependency injection. We should inject class B in class A and use it through a provided reference.
Using classes everywhere
TypeScript has both classes and interfaces; usually we cannot live without classes, and if we have to choose, lots of developers often opt for using only classes, and not using interfaces at all. But this is a path to some nasty pitfalls. Imagine the scenario: we have a service that make a data call to get some Product
data. HttpClient
's methods are generic, so we can specify what the response data is. Naturally, we will write something like this:
@Injectable()
export class ProductService {
constructor(
private readonly http: HttpClient,
) { }
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products');
}
}
Now what is Product
? Let's suppose it is a class:
export class Product {
name: string;
price: number;
amountSold: number;
}
Seems nice. So where is the problem? The backend does for sure return this precise object, so this should be working, right? Well, it will, until someone decides to interfere:
export class Product {
name: string;
price: number;
amountSold: number;
get total() {
return this.price * this.amountSold;
}
}
As you can see, someone just added a getter method to our class, and this will prove to be a problem. Let's look at a component that uses our service and class:
@Component({
selector: 'app-product-list',
template: `
<div *ngFor="let product of products">
<span>{{ product.name }} - {{ product.price }}</span>
<span>{{ product.total }}</span>
</div>
`,
})
export class ProductListComponent {
products: Product[];
constructor(
private productService: ProductService,
) {}
ngOnInit() {
this.productService.getProducts().subscribe(products => {
this.products = products;
});
}
}
Now we can spot the problem: our service loads the products, but it does not ensure the data is 100 percent corresponding with what we have in our class, and the total
getter method is not being added; so TypeScript won't catch an error, but we will end up with an empty slot in our UI where the total price should be.
With interfaces, though we can add method declarations too, developers are not tempted to do that unless the interfaces are then implemented by classes. Another approach is to use type definitions instead of interfaces:
export type Product = {
name: string;
price: number;
amountSold: number;
}
You can read more about differences between types and interfaces here.
Not using classes where necessary
Sometimes we need some utility functions in our applications to perform routing actions with data:
export function isObject(obj: any): obj is object {
return obj !== null && typeof obj === 'object';
}
export function isArray(obj: any): obj is any[] {
return Array.isArray(obj);
}
export function copy<T>(obj: T): T {
if (isObject(obj)) {
return JSON.parse(JSON.stringify(obj));
} else {
return obj;
}
}
// and so on...
Of course, we might not know where exactly to put those, and just put them in some file named functions.ts
for example, and be done with it.
This is something that should better be decided by you (and your team), as this comes both with tradeoffs and benefits. Using functions instead of classes allows for better tree-shaking, on the other hand, they might become harder to mock in unit testing.
Insanely large classes
This has been said over and over, but even the most experienced developers fall into this trap: sometimes our components contain large amounts of logic, all of which is necessary. Huge classes are harder to read, reason about and test. Angular codebases are especially guilty of this; mainly because components are very often pages (as in routing), and modern web pages can contain ridiculous amounts of logic, checks and stuff that will make our classes bloated. So it is a good practice to break down our components into smaller classes, each with a specific responsibility (single responsibility principle). But we should remember to start breaking down only when an existing class grows larger, and only start with a broken down approach right away only if we know that the very first implementation of the class is going to be large by design. Usually a good approach to this is to watch when a class becomes difficult to work with and refactor.
Don’t optimize prematurely
Having services for specific components
What we often encounter in Angular projects is when specific components have kind of dedicated services for themselves, for example, a HomePageComponent
might have a HomePageService
that specifically loads all the data required by the home page. While this sounds like a nice separation of concerns, it still tightly couples the HomePageComponent
with the service. In the future, it is possible we might need some methods from that service in, say, SideBarComponent
, which will be strange at the very least - home age might not even be loaded, but its dedicated services will already be working because of the sidebar. Also, it kind of obscures the purpose of the service: it is not readily apparent what sort of data the HomePageService
is working with. So, a better approach would be creating different services working with specific sorts of data and business logic; for example, we might have a UserService
, a PermissionsService
, a ProductService
, and so on, and use them in appropriate components when necessary.
In Conclusion
In this article, we have explored some common malpractices with OOP in Angular codebases. While there are lots of other programming paradigms (some also used by Angular projects like function programming, reactive programming and so on), OOP has a central place in the Angular ecosystem, so using it correctly is paramount if we want to achieve flexibility and maintainability.