The purpose of this article is to provide you with a small, strongly-typed utility pipe that you can easily incorporate into your project to improve its performance.
This article will also provide the solution for challenge #9 of Angular Challenges, which is intended for intermediate-level developers seeking to enhance their understanding of pipes. If you haven’t tried it yet, I encourage you to do so before coming back to compare your solution with mine. (You can also submit a PR that I’ll review)
In this article, we will not delve into the workings of pipes or how they can benefit your Angular application. If you wish to learn more about these topics, please refer to my previous article.
As mentioned in the previous article, calling a function inside a template can have a significant impact on performance as the function will be recomputed every time change detection is executed. We can mitigate this by adding a memo
function to cache the input value, as described in the above article. However, this approach has a limitation in that we can only use the memo function once per input.
The solution to this problem is to move our function inside a pipe. However, if we have multiple functions to move, we would need to create a new pipe for each function, which could become cumbersome.
In challenge #9 of Angular Challenge, we have the following component:
@Component({
standalone: true,
imports: [NgFor],
selector: 'app-root',
template: `
<div *ngFor="let person of persons; let index = index; let isFirst = first">
{{ showName(person.name, index) }}
{{ isAllowed(person.age, isFirst) }}
</div>
`,
})
export class AppComponent {
persons = [
{ name: 'Toto', age: 10 },
{ name: 'Jack', age: 15 },
{ name: 'John', age: 30 },
];
showName(name: string, index: number) {
// very heavy computation
return `${name} - ${index}`;
}
isAllowed(age: number, isFirst: boolean) {
if (isFirst) {
return 'always allowed';
} else {
return age > 25 ? 'allowed' : 'declined';
}
}
}
The for loop in our code is currently calling two functions for each person in the array. These functions are being recomputed at each change detection cycle, which can cause performance issues.
As mentioned previously, we could create a separate ShowNamePipe
and IsAllowedPipe
for each function, but this would require creating a large number of pipes for every component in our application.
To solve this problem, we can wrap our function inside a generic pipe called WrapFnPipe
, as shown below:
@Pipe({
name: 'wrapFn',
standalone: true,
})
export class WrapFnPipe implements PipeTransform {
transform(func: (...arg: any[]) => R, ...args: any[]): R {
return func(...args);
}
}
To refactor our HTML with this pipe, the first argument is the function we want to wrap, then we pass all the function arguments separated by :
. We can now rewrite our HTML to the following:
<div *ngFor="let person of persons; let index = index; let isFirst = first">
{{ showName | wrapFn : person.name : index }}
{{ isAllowed | wrapFn : person.age : isFirst }}
</div>
By using this method, we can obtain all the benefits of Angular pipe without creating a specific pipe for each function. Each function is wrapped inside its own instance of the WrapFnPipe
, which means each person has its own cached value. Therefore, when a new change detection is executed, the function is not re-executed and the last cached value is returned, resulting in an immediate performance boost.
However, introducing this wrapping function removes all type safety as it remaps all types to any
. To add type safety, let’s explore some options.
A naive approach to adding type safety would be to use generics and replace all any
types with a generic type:
transform<ARG, R>(func: (...arg: ARG[]) => R, ...args: ARG[]): R {
return func(...args);
}
However, this approach will only work if all arguments of the function are of the same type.
In our case, we will encounter an error because the generic type ARG
only takes the first type, which is string
in our case, and not string | undefined
. This approach would not be very type safe either because we would be able to invert both arguments.
To address this issue, we need to explore TypeScript’s function overloading feature and take advantage of it. If you are not familiar with function overloading or how it works, you should read the following article first and come back to this article afterwards.
Using function overloading, we end up with this pipe transform
function:
@Pipe({
name: 'wrapFn',
standalone: true,
})
export class WrapFnPipe implements PipeTransform {
transform<ARG, R>(func: (arg: ARG) => R, args: ARG): R;
transform<ARG1, ARG2, R>(
func: (arg1: ARG1, arg2: ARG2) => R,
arg1: ARG1,
arg2: ARG2
): R;
transform<ARG1, ARG2, ARG3, R>(
func: (arg1: ARG1, arg2: ARG2, arg3: ARG3) => R,
arg1: ARG1,
arg2: ARG2,
arg3: ARG3
): R;
transform<ARG1, ARG2, ARG3, R>(
func: (arg1: ARG1, arg2: ARG2, arg3: ARG3, ...arg: any[]) => R,
arg1: ARG1,
arg2: ARG2,
arg3: ARG3,
...arg: any[]
): R;
transform<R>(func: (...arg: unknown[]) => R, ...args: unknown[]): R {
return func(...args);
}
}
Note:
- The first 4 functions are just definitions for different
transform
methods. The last one is the actual implementation that combines all of the definitions. - The first definition is for a one argument function, the second for a two arguments function and so on. We stop at 4 because it is bad design to have too many arguments, but if you want to, more definitions can be added.
By doing so, we have strong type safety in our template for any function. This includes type safety on the number of parameters, as well as type safety on the type of each input, as shown in the following screenshot:
I hope this article has helped you understand the power of pipes and what can be achieved with TypeScript. This pipe is a quick win and can be added to your project right away to boost some very costly operations without the need to refactor everything. 🚀
Top comments (3)
Thank you! I finally understood places where I shouldn't put generics after the class names.
I've seen a similar implementation, but with the difference that the arguments were used in reverse order. That is, the pipe was applied to the value, and the mapping function was passed as an argument to the pipe. Your solution probably looks better, as it's easier to use with an unlimited number of arguments.
Thanks for sharing.
Yes you can do it as well and passing the rest of the argument after the name of the function. But it's harder to read on the template, feel less natural.