Opening Thoughts

For quite some time now, I’ve been making the case that Directives are the aspect of Angular we tend to overlook the most. They offer a robust toolkit for crafting template magic, yet in most codebases, they’re limited to the typical "attribute directive performing a bit of business logic" pattern.

Closely following is dependency injection. It’s a powerful mechanism for constructing reusable components, but in the vast majority of Angular projects, DI is reserved almost exclusively for services.

I’ve covered both subjects in several past pieces, and the following list of articles is worth checking out before you continue here—though it’s not a strict prerequisite:

In this multi-part series, we’re going to go deeper and see how these two features—often working hand in hand—can dramatically streamline our templates. We’ll work through concrete use cases, building step by step.

A quick note: these examples aren’t chosen because they represent the most frequent or most practical real-world scenarios; in many cases, there are already third-party libraries that solve these problems. They’re picked for educational value, as they let us cover a broad range of concepts in a compact amount of code.

That’s enough of a preamble—let’s jump in.

Constructing a password strength indicator

Checking password strength is a common feature across many web applications today. While there are plenty of existing libraries, let's craft our own solution with a strong focus on flexibility.

We begin with the most basic approach: attaching a class to the input element for visual feedback.

type PasswordStrength = 'weak' | 'medium' | 'strong';

@Directive({
  selector: '[appPasswordStrength]',
  standalone: true,
})
export class PasswordStrengthDirective {
  private readonly el: inject(ElementRef);

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    const input = event.target as HTMLInputElement;
    const value = input.value;
    const strength = this.evaluatePasswordStrength(value);
    this.el.nativeElement.classList.add(
      `password-strength-${strength}`
    );
  }

  evaluatePasswordStrength(password: string): PasswordStrength {
    if (password.length < 6) {
      return 'weak';
    } else if (password.length < 10) {
      return 'medium';
    }
    return 'strong';
  }
}
Enter fullscreen mode Exit fullscreen mode

This directive is then referenced in the template:

<input type="password" appPasswordStrength>
Enter fullscreen mode Exit fullscreen mode

This setup is straightforward. (The evaluation logic's simplicity is not our concern here; we can swap in any logic we want. Our primary goal is maximizing the directive's customizability).

However, this initial implementation presents several challenges:

  1. Why is an explicit selector necessary? If the [appPasswordStrength] attribute is forgotten, the directive won't execute. Is there a way to make it apply automatically to every password input?
  2. What if the required behavior is not just adding a CSS class, but, for example, injecting text into the DOM? Can the directive simply broadcast the password strength and let the template handle the display?
  3. How can we enable customization of the evaluation function? Is it possible to allow the user to supply their own logic for assessing password strength?
  4. Given the need for a custom evaluator, can we facilitate its provision both globally (from a single source) and individually for specific inputs?

Let's tackle these challenges step by step, starting with the simplest one:

@Directive({
  selector: 'input[type="password"]',
  standalone: true,
})
// directive implementation
Enter fullscreen mode Exit fullscreen mode

Now, we can simply remove the attribute selector:

<input type="password">
Enter fullscreen mode Exit fullscreen mode

This change makes the directive work automatically. But what if we need to disable the checking on certain inputs? We can add an input property for that:

@Directive({
  selector: 'input[type="password"]',
  standalone: true,
})
export class PasswordStrengthDirective {
  @Input() noStrengthCheck = false;
  private readonly el: inject(ElementRef);

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    if (this.noStrengthCheck) {
      return;
    }
    // logic goes here
  }

  // the other methods
}
Enter fullscreen mode Exit fullscreen mode

Here's how to use this new option:

<input type="password" [noStrengthCheck]="true">
Enter fullscreen mode Exit fullscreen mode

This solves the first improvement. Next, let's modify the directive so it doesn't add a class itself, but instead exposes the password strength for the template to handle. One way is through an output, but that would burden developers with extra boilerplate to capture the strength value in a variable. Therefore, we'll use exportAs to allow direct access to the directive instance:

@Directive({
  selector: 'input[type="password"]',
  standalone: true,
  exportAs: 'passwordStrength',
})
export class PasswordStrengthDirective {
  @Input() noStrengthCheck = false;
  // property to capture in the template
  strength: PasswordStrength = 'weak'; 
  // no need for ElementRef anymore

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    if (this.noStrengthCheck) {
      return;
    }
    this.strength = this.evaluatePasswordStrength(value);
  }

  evaluatePasswordStrength(password: string): PasswordStrength {
    if (password.length < 6) {
      return 'weak';
    } else if (password.length < 10) {
      return 'medium';
    }
    return 'strong';
  }
}
Enter fullscreen mode Exit fullscreen mode

We now write the strength value directly to a property, allowing the developer to easily capture it in the template. Here is the implementation:

<input type="password" #evaluator="passwordStrength">
<div *ngIf="evaluator.strength === 'weak'">Weak password</div>
<div *ngIf="evaluator.strength === 'medium'">Medium password</div>
<div *ngIf="evaluator.strength === 'strong'">Strong password</div>
Enter fullscreen mode Exit fullscreen mode

By employing exportAs, we capture the directive instance in a template variable, granting access to its strength property. Further details can be found in the official documentation.

Our next step is to allow developers to inject their own logic for evaluating password strength. While a standard Input property is an option, it would require providing the function for every password input, which is repetitive and prone to omission. Instead, we'll use an InjectionToken with a small helper to make the logic available application-wide:

type PasswordEvaluatorFn = (password: string) => PasswordStrength;

export const evaluatorFnToken = new InjectionToken<
  PasswordEvaluatorFn
>(
  'PasswordEvaluatorFn',
);

export function providePasswordEvaluatorFn(
  evaluatorFn: PasswordEvaluatorFn,
) {
  return [{
    provide: evaluatorFnToken,
    useValue: evaluatorFn,
  }];
}

@Directive({
  // eslint-disable-next-line @angular-eslint/directive-selector
  selector: 'input[type="password"]',
  exportAs: 'passwordEvaluator',
  standalone: true,
})
export class PasswordEvaluatorDirective {
  strength: PasswordStrength = 'weak';
  @Input() evaluatorFn = inject(evaluatorFnToken);
  @Input() noStrengthCheck = false;

  @HostListener('input', ['$event'])
  onInput(event: InputEvent) {
    if (this.noStrengthCheck) {
      return;
    }
    const input = event.target as HTMLInputElement;
    const value = input.value;
    this.strength = this.evaluatorFn(value);
  }
}
Enter fullscreen mode Exit fullscreen mode

With this in place, we can provide a custom evaluation function globally:

bootstrapApplication(AppComponent, {
  providers: [
    providePasswordEvaluatorFn((password: string) => {
      if (password.length < 6) {
        return 'weak';
      } else if (password.length < 10) {
        return 'medium';
      }
      return 'strong';
    }),
  ],
  // the rest of the application
});
Enter fullscreen mode Exit fullscreen mode

Then we can apply it as needed.

This raises a question: what happens if no custom evaluator is supplied? We could make the directive throw an error, but that isn't user-friendly. The preferable approach is to fall back to a default evaluator. However, if a custom function isn't provided, DI will raise a NullInjectorError. This is where the optional flag becomes invaluable:

@Directive({
  //...
})
export class PasswordEvaluatorDirective {
  //...
  evaluatorFn = inject(evaluatorFnToken, { optional: true });
  //...
}
Enter fullscreen mode Exit fullscreen mode

Now, the inject function will return null instead of throwing when the token is missing. This enables us to define a safe default evaluator:


export const defaultEvaluatorFn: PasswordEvaluatorFn = (
  password: string,
): PasswordStrength => {
    if (password.length < 6) {
        return 'weak';
    } else if (password.length < 10) {
        return 'medium';
    }
    return 'strong';
}

@Directive({
  //...
})
export class PasswordEvaluatorDirective {
  //...
  evaluatorFn = inject(
   evaluatorFnToken,
   { optional: true },
  ) ?? defaultEvaluatorFn;
  //...
}
Enter fullscreen mode Exit fullscreen mode

Now, if a developer is happy with the default, they don't need to configure anything. If they need custom behavior, they can provide their own function, either at the component level or globally.

Our final question concerns per-input customization. Suppose a single component has multiple password fields, and they need different evaluators. Given how inject works, we can achieve this by simply decorating our evaluatorFn with @Input:

@Directive({
  //...
})
export class PasswordEvaluatorDirective {
  //...
  @Input() evaluatorFn = inject(
    evaluatorFnToken,
    { optional: true },
  ) ?? defaultEvaluatorFn;
  //...
}
Enter fullscreen mode Exit fullscreen mode

This setup allows for usage like this:

<input type="password" 
       #evaluator="passwordEvaluator"
       [evaluatorFn]="myEvaluatorFn"/>
Enter fullscreen mode Exit fullscreen mode

Here is the finalized version of our component, complete with a live demonstration:

Wrapping Up

We have seen how InjectionToken can supply a directive with bespoke behavior, how exporting a directive instance gives other parts of the template a handle on it, and how a tailored selector widens the net for element matching. Next time, we turn to structural directives and more intricate DOM work.