What is a mixin? As per version 2.2, TypeScript now supports the concept of a mixin - a function that can take a class, extend it with some functionality, and then return the new class, allowing other classes to extend from it - allowing classes to mix and share functionalities!
The concept is fairly uncomplicated - if we are familiar with inheritance, higher-order classes/functions and their syntax, we can jump right into them. Here is the example from the TypeScript documentation itself:
class Point {
constructor(public x: number, public y: number) {}
}
class Person {
constructor(public name: string) {}
}
type Constructor<T> = new(...args: any[]) => T;
function Tagged<T extends Constructor<{}>>(Base: T) {
return class extends Base {
_tag: string;
constructor(...args: any[]) {
super(...args);
this._tag = "";
}
}
}
const TaggedPoint = Tagged(Point);
let point = new TaggedPoint(10, 20);
point._tag = "hello";
class Customer extends Tagged(Person) {
accountBalance: number;
}
let customer = new Customer("Joe");
customer._tag = "test";
customer.accountBalance = 0;
As we see, we use a function here to create an enriched version of another class, which can be used both to instantiate new objects and to extend other classes. In a sense, this now allows for multiple inheritance - if some of our classes are only needed to share functionality (abstract class), then we can write it inside a function, so it can be mixed with other classes for further composition.
Imagine a usual Angular application with different pages, some of which have forms in them. All is well, then one day we decide, that from now on, if the user has touched a form, but tries to leave the page without submitting it, a window will be displayed, asking the user to confirm if they really want to leave the page (a very standard feature).
Of course, the first thing that comes to mind is a
Guard
. Because our app is designed in a good fashion, most of the (but not all!) components with forms in them have a generic form
field, which is an instance of AbstractFormControl
. Of course, from then we can check the touched
field of the form
property and find out whether it is necessary to show a prompt. The guard can look like this:export class FormTouchedGuard implements CanDeactivate<{form: AbstractFormControl}> {
canDeactivate(component) {
return component.isFormTouched() ? confirm('Are you sure you want to leave?') : true;
}
}
Of course, this looks good on paper, but in reality, as mentioned briefly above, not every single page works like this - some pages have multiple forms, some pages have forms inside their child components, some have both. Of course, we want the guard to work in the same way for each component, and we also want to be very precise and consistent. So here is a thought: from now on, every component that should have this guard on it, should implement a special method called
isFormTouched
, which will return a boolean
to tell the guard if it has to show the prompt or not. Here is an example of a more complex component, who has a form inside itself and another form inside its child, and how it implements the method:interface FormCheck {
isFormTouched(): boolean;
}
@Component({...})
export class SomeComponent implements FormCheck {
@ViewChild('nested_component') nested: SomeOtherComponentWithForm;
isFormTouched() {
return this.nested.form.touched;
}
}
Of course, this is great, but for most components (like, 90%) the
isFormTouched
method comes down to just this:@Component({...})
export class SomeComponent implements FormCheck {
form: FormGroup;
isFormTouched() {
return this.form.touched;
}
}
Of course, we can copy and paste this method to each and every component in which we need it, but that action in itself already smells fishy, right? Another reason we don't want a solution like this is because one day, it may be also required to check if the form has already been submitted (using an
isSubmitted
property on the same class, for example). Of course, that would require us to rewrite lots of code. And if we miss one - it may be ages before someone finds out such a minor bug. So, naturally, we want a solution which lets us write that particular method just once, but still sharing it among all our components that need it. Obviously, inheritance comes to mind - but here are three fundamental downsides to it:trait
is present, which allows such things, but in JS we don't have such a feature, so our classes should represent something, not just contain one simple method.Take a look at this function:
function WithFormCheck<T extends Constructor<{}>>(Base: T = (class {} as any)) {
return class extends Base implements FormCheck {
form: FormGroup;
isFormTouched() {
return this.form.touched;
}
}
}
As you see, this function takes a simple class (which can also be an Angular component) and returns another component, which extends from it and also has the
isFormTouched
method implemented on it. We can than do this simple thing:@Component({...})
export class SomeComponent extends WithFormCheck() {
// other code
}
Here is how this functions solves all of the three issues mentioned above:
WithFormTouchedCheck
, it now represents a class (which it can get as an argument - or without it, notice the Base: Constructor<T> = (class {} as any)
line, which basically means that if no class is given, en empty one will be extended) that was enhanced with some additional features.I am sure many of us have heard the scary stories about zombie subscriptions, and how we can avoid them by using the
takeUntil
operator. Of course, we would also need to create a specific Subject
for that, and send a next
notification inside our ngOnDestroy
method. In fact, here is the code:export class HasSubscriptionComponent implements OnDestroy {
destroy$ = new Subject<void>()
ngOnDestroy() {
this.destroy$.next();
}
}
Of course, many of our components may have subscriptions inside them, and all of them should implement this same functionality, so it makes sense to make it a mixin:
function WithDestroy<T extends Constructor<{}>>(Base: T = (class {} as any)) {
return class extends Base implements OnDestroy {
destroy$ = new Subject<void>()
ngOnDestroy() {
this.destroy$.next();
}
}
}
Of course, a component that uses our previous mixin may also need this new one, but it is extremely easy to combine them:
export class HasSubscriptionComponent extends WithDestroy(WithFormCheck()) {
// other code
}
Be careful: we implemented the
ngOnDestroy
method in our mixin: if you are going to use it in a class that will also itself implement the ngOnDestroy
method, be sure to call super.ngOnDestroy()
inside it!Of course, every approach in programming may have some downsides to it. Most of them in our case are related either to some Typescript compiler specific issues, or to Angular build issues. Here I present two of them, which may be the most frustrating ones.
1.
Decorators are not valid here
issue:If we try to use a decorator inside our mixin like this:
function WithInputs<T extends Constructor<{}>>(Base: T = (class {} as any)) {
return class extends Base {
@Input() type;
}
}
We will get an error which says
Decorators are not valid here
. This is in fact an issue with Typescript compiler. Here is a workaround:function WithInputs<T extends Constructor<{}>>(Base: T = (class {} as any)) {
class Temporary extends Base {
@Input() type;
}
return Temporary;
}
Somehow Typescript is only okay when we put decorators on a named class.
2. Angular Inputs issue:
If we include a decorator on one of our mixin classes to, for example, define an
Input
property on the class, it will work as intended when we ng serve
our application, but will throw an error during a production build. This is related to this issue. A workaround for this is a bit messier:@Component({
// other metadata
inputs: ['type'],
})
export class SomeOtherComponent extends WithInputs() {
// other code
}
This is of course more work to do on a child component, and Angular's own style guide does not approve the usage of the
inputs
array in the decorators, but this is still a workaround. I personally don't mind it, until the Angular team provides us with a solution.Typescript mixins are a great way to enhance our Angular app with shared functionality without breaking any flow. While this technology still has some small downsides, I personally believe its benefits vastly outweigh them.