skip to content

Custom Transforms & Purity

@Pipe with PipeTransform defines a template transform; a pure pipe reruns only when an input reference changes, an impure one on every check. Interviewers probe purity and mutation.

part ofAngularoverview, primer and where to startread it →
on this pageshow

explore

questions

5

How do you write a custom Angular pipe with @Pipe and PipeTransform, give it an extra argument, and use it in a standalone component?

level: juniorimportance: must knowfreq 62%

answer

  1. a decorator plus one method
  2. first parameter is the piped value
  3. extra parameters become colon arguments
  4. import it where it is used

basics

~10 s

Decorate a class with @Pipe({ name: 'truncate' }), implement PipeTransform's transform(value, ...args), and add the class to the standalone component's imports. Extra transform parameters become colon-separated arguments, as in text | truncate: 40.

solid answer

~40 s

A custom pipe is a class with `@Pipe({ name: 'truncate' })` that implements `PipeTransform`. Its `transform(value, max = 20, suffix = '...')` receives the piped value first and each extra argument in order, written in the template as `{{ text | truncate: 40 : ' [more]' }}`. Since v19 pipes are standalone by default, so I add `TruncatePipe` to the component's `imports`. Pipes are pure by default, which is right when output depends only on the parameters. I handle `null`/`undefined`, keep the name camelCase with no hyphens, and move the logic into a plain function the pipe delegates to, so other code can reuse it without injecting the pipe.

code

ts · 21 lines
ts
import { Component, Pipe, PipeTransform, input } from '@angular/core';

export function truncate(value: string, max: number, suffix: string): string {
  return value.length > max ? value.slice(0, max) + suffix : value;
}

@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
  transform(value: string | null | undefined, max = 20, suffix = '...'): string {
    return value ? truncate(value, max, suffix) : '';
  }
}

@Component({
  selector: 'app-note-card',
  imports: [TruncatePipe],
  template: `<p>{{ body() | truncate: 40 : ' [more]' }}</p>`,
})
export class NoteCard {
  body = input.required<string>();
}

go deeper

for a junior

Recall the @Pipe decorator with a name, the transform method, colon arguments and importing the pipe into a standalone component.

for a middle

Explain the defaults (pure, standalone since v19), strict typing of transform, null handling and how registration differs in NgModule code.

for a senior

Structure pipes so logic lives in plain functions, is unit-tested once and is reused by services without injecting pipe classes.

for a principal

Decide which transformations deserve shared pipes in a design system and which belong in data preparation, to keep templates thin.

## What a custom pipe is A **pipe** is a class Angular calls from a template expression through the `|` operator. Angular ships formatting pipes, but any project can add its own for transformations that repeat across templates: shortening text, formatting file sizes, mapping a status code to a label. A custom pipe has two required parts: - the **`@Pipe` decorator** with a `name`, the identifier used in templates; - a **`transform` method**, which receives the value on the left of `|` as its first parameter and returns what the template renders. The class should also implement the **`PipeTransform`** interface from `@angular/core`, which makes TypeScript check that `transform` exists with a compatible shape. ## A complete example ```ts import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'truncate' }) export class TruncatePipe implements PipeTransform { transform(value: string | null | undefined, max = 20, suffix = '...'): string { if (!value) return ''; return value.length > max ? value.slice(0, max) + suffix : value; } } ``` Using it from a standalone component: ```ts @Component({ selector: 'app-note-card', imports: [TruncatePipe], template: `<p>{{ note().body | truncate: 40 }}</p>`, }) export class NoteCard { note = input.required<{ body: string }>(); } ``` ## Arguments Every parameter after the first becomes a **pipe argument**. In the template each argument follows a colon, in declaration order: `{{ text | truncate: 40 : ' [more]' }}`. Default parameter values in `transform` make arguments optional, and the template type checker checks each argument against the parameter types. ## Metadata you should know | `@Pipe` option | Meaning | Default | |---|---|---| | `name` | The template name; camelCase, no hyphens | required | | `pure` | Re-run only when the value or an argument changes | `true` | | `standalone` | Usable through a component's `imports` without an NgModule | `true` since v19 | Naming follows the Angular guide's convention: the `name` in camelCase (`truncate`, `fileSize`) and the class in PascalCase with a `Pipe` suffix (`FileSizePipe`). ## Registering it 1. **Standalone components** (the default for new code): add the pipe class to the component's `imports` array. 2. **NgModule-based code**: add a standalone pipe to the module's `imports`, or, for an older non-standalone pipe, to the module's `declarations` and `exports`. A pipe used in a template but not imported fails at compile time with an error saying no pipe of that name was found. ## Good habits - **Keep `transform` pure in the everyday sense**: its output should depend only on its parameters. That is what makes the default `pure: true` correct. - **Handle `null` and `undefined`**: template values are often not loaded yet. - **Put the logic in a plain function** and let `transform` delegate to it. The Angular guide recommends this so services can reuse the logic without injecting the pipe class, which is not designed to be an injectable service. - **Type the signature precisely**, so misuse such as `{{ count | truncate }}` on a number is caught by strict template checking. ## Testing a simple pipe A pipe without dependencies is an ordinary class, so the fastest test needs no `TestBed` at all: ```ts const pipe = new TruncatePipe(); expect(pipe.transform('Angular pipes', 7)).toBe('Angular...'); expect(pipe.transform(null)).toBe(''); ``` Test the edge cases the template will hit: `null` while data loads, a value exactly at the limit, and the default arguments. If the logic lives in a plain function, test that function and keep one smoke test for the pipe. ## What a pipe is not for Pipes transform a value for display. They are not the place for side effects such as HTTP calls or writing to state, and they cannot be used in event bindings. When the result depends on data other than the arguments, the pipe must either receive that data as an argument or be marked impure, which is a separate discussion about purity.

  • What happens if a template uses {{ x | truncate }} but the component does not import TruncatePipe?
    The template compiler cannot resolve the name and reports that no pipe called truncate was found. Standalone components must list every pipe they use in `imports`; NgModule-based components get pipes from their module's imports or declarations.
  • Why does the Angular guide say not to inject a pipe class into a service?
    Pipes are template operators, not injectable services. Put the logic in a plain exported function, let the pipe's `transform` delegate to it, and import the function in the service. Built-in pipes have equivalents such as `formatDate` and `formatNumber`.

saying these in an interview costs you the question

  • Names a pipe with hyphens like 'my-truncate'
  • Thinks a pipe must be declared in an NgModule in current Angular
  • Believes pipes are impure unless marked pure: true
  • Passes pipe arguments as a parenthesised list
  • Injects the pipe class into services to reuse its logic
open as a page

In Angular, what is the difference between a pure and an impure pipe, and why does a pure pipe miss an item pushed into an array?

level: middleimportance: must knowfreq 70%

basics

~20 s

A pure pipe (the default) re-runs only when its value or an argument changes by Object.is, so pushing into the same array is invisible. An impure pipe (pure: false) runs on every check of its view.

open as a page

In an Angular template, how does {{ formatPrice(item) }} differ from {{ item.price | price }} in when your code actually runs?

level: middleimportance: should knowfreq 50%

basics

~20 s

A method call in a binding runs every time the view is checked, because Angular cannot know its dependencies. A pure pipe runs only when its value or arguments change, reusing its previous result otherwise.

open as a page

A custom Angular timeAgo pipe keeps showing '3 minutes ago' long after; why does it never update, and how would you design it to refresh correctly?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The pure pipe reads Date.now() internally, but its only input never changes, so transform never re-runs. Pass a shared now signal as an argument, updated by one timer, so the pure pipe re-runs when time moves.

open as a page

In Angular, how do you inject a service into a custom pipe, which injector resolves it, and how many pipe instances does a template create?

level: middleimportance: nice to knowfreq 25%

basics

~10 s

Call inject() in the pipe class; it resolves from the element injector where the pipe is used, then environment injectors, without the host's viewProviders. Each usage in each view gets its own pipe instance.

open as a page