Wrapping Functions in a Pipe: Creating a Small, Typed Performance Utility
This article presents a small, strictly typed utility pipe you can drop into your project for an immediate performance win. We'll walk through how this pipe solves a common performance pitfall in Angular templates, and we'll use challenge #9 from Angular Challenges as our working example. It's aimed at intermediate developers who want to sharpen their pipe skills. If you haven't already tried solving that challenge yourself, I'd encourage you to give it a go first—then come back to compare your solution with mine. (Bonus: you can also submit a PR for me to review!)
To keep focus, this post won't re-explain how pipes work under the hood or their many benefits in Angular. For a refresher, you can check out my earlier article on the topic.
As explained there, invoking a function directly inside a template can drag down performance—Angular re-runs that function on every single change detection cycle. One way to soften that blow is to use a memo function that caches the input value. Yet that trick only works for a single input; the moment you have multiple inputs, that memoization approach falls apart.
The more robust answer is to move the function behind a pipe. But if you had several functions to call in your template, you'd end up creating one bespoke pipe per function—a tedious, unwieldy approach.
Let's look at the component from challenge #9 to see this in action:
@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 loop iterates over an array of people, and for each item it invokes two functions. Those calls are recomputed at every change detection pass, which is exactly where the performance tax comes in.
You could of course create individual ShowNamePipe and IsAllowedPipe components for those two functions, but multiply that by every component in a real app and the task becomes impractical.
The clean solution is a single generic WrapFnPipe that takes your function definition and its arguments, like this:
@Pipe({
name: 'wrapFn',
standalone: true,
})
export class WrapFnPipe implements PipeTransform {
transform(func: (...arg: any[]) => R, ...args: any[]): R {
return func(...args);
}
}
In the template you use it by passing the pure function first, followed by its arguments separated by : as seen below:
<div *ngFor="let person of persons; let index = index; let isFirst = first">
{{ showName | wrapFn : person.name : index }}
{{ isAllowed | wrapFn : person.age : isFirst }}
</div>
Every function call now runs inside its own instance of WrapFnPipe. Each instance caches the result based on its inputs, so when change detection fires again, the pipe simply returns the previously computed value rather than re-executing the function—a direct and immediate performance improvement.
That said, wrapping functions this way comes with a cost: the types get flattened to any. We can restore full type safety; let's go over a few options.
A first instinct is to reach for generics and swap out any with type parameters:
transform<ARG, R>(func: (...arg: ARG[]) => R, ...args: ARG[]): R {
return func(...args);
}
However, this only works when all the function parameters share the same type.
In our case, that's a problem: the single generic ARG picks up only the first type—string—and completely misses the string | undefined of the second argument. Furthermore, this approach allows you to accidentally swap the order of the arguments, which is far from type safe.
This is where TypeScript's function overloading feature comes in handy. If you haven't worked with overloads yet, I'd recommend reading this article first before continuing here.
Function Overloading in Typescript
thomas for Playful Programming Angular ・ Mar 27 '23
With function overloading, the pipe's transform method becomes:
@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 four declarations only describe the different overloads of the
transformmethod. The final function is the actual implementation that binds them all together. - Each overload corresponds to a different number of arguments: the first one handles a single argument, the second handles two, and so on. Arduinoing at four for no reason beyond that having too many parameters is a poor practice—but you can easily extend the list if you wish.
With this setup, we get strong type checking on functions called from the template. Both the number of arguments and each argument's specific type are validated, as you can see in the following screenshots:
I hope this gives you a taste of what's possible when you combine Angular pipes with TypeScript's type system. The WrapFnPipe is a simple, low-risk addition that can ease costly template computations right away—no large-scale refactoring required. 🚀



