Reactive Form Custom Validators Banned Words

When creating forms we use validation to ensure the quality and integrity of our data. Even though security cannot be enforced on the front end since the guardrails we provide can easily be bypassed using dev tools, validation and the resulting feedback is paramount for user experience (UX). Providing proper instructions and exposing validation errors in a human-readable way helps guide the user in successfully filling out and submitting a form. Many field validation options such as length and minimum/maximum values are available as part the HTML specification, but sometimes we need something more customized to our needs. One such situation is when creating an input where certain words should be disallowed, a common use case for preventing the use of profanity. Let's look at how we can create a custom validator for an Angular reactive form that prevents users from using any words included in an arbitrary list. For this example, we will be banning the words "JavaScript", "Typescript", and "Angular".

Setup: Creating the form

We will create a reactive form with one textarea labelled "Comments".

banned-words.component.html - Form HTML
<form [formGroup]="form">
  <label>Comments</label>
  <textarea id="comments" #comments formControlName="comments" rows="5"></textarea>
</form>

To create the formGroup, we can use FormBuilder provided by @angular/forms, to which we pass an array containing our initial value of null and an empty array of validators. We will add to this array shortly, but for now:

banned-words.component.ts - Form Typescript
import { Component } from '@angular/core';
import { FormBuilder, ValidationErrors, Validators } from '@angular/forms';

@Component({
  selector: 'app-banned-words',
  templateUrl: './banned-words.component.html',
  styleUrls: ['./banned-words.component.scss'],
})
export class BannedWordsComponent {

  form = this.fb.group({ comments: [null, []]})
  
  constructor( private fb: FormBuilder) { }

}

The first validator we are going to add will check the maximum length of the comment. This is a ready-made validator available through Validators, which we import from @angular/forms. We can then add Validators.maxLength(250) to our array of validators, limiting the length of the comment to 250 characters.

banned-words.component.ts - Max Length Validator
import { FormBuilder, Validators } from '@angular/forms';

@Component( ... )
export class BannedWordsComponent {

  form = this.fb.group({ comments: [null, [ Validators.maxLength(250) ]]})
  constructor( private fb: FormBuilder) { }

}

Validators provides a number of ready-made validation functions for us to use that map directly to what's already available in HTML -- and maybe a little more -- but none that will allow us to check for banned words. We're going to have to create that one ourselves.

Validator Function

The first part of creating a custom validator is a function to check whether or not the value applied to that field is valid. Let's create a file just for this function which will then be usable in any component we want to import it into. In this file we will create a function that returns a validator function (ValidatorFn).

banned-words.validator.ts
import { ValidatorFn } from '@angular/forms';

export function bannedWordValidator(): ValidatorFn {

}

Now let's add the ValidatorFn inside of our first function. The newly-created anonymous function will return our errors. It's important to note that the validation does not return the validity of the input. If the input is valid it will return null. If the input is not valid, it will return the error information.

banned-words.validator.ts - Validator Function
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function bannedWordValidator(): ValidatorFn {

  return (control: AbstractControl): ValidationErrors | null => {
 
  }
}

Now we can start adding our logic! Notice that our innermost anonymous function takes a parameter. This is the control our validator will be applied to. From this control we can read the input value using control.value.

banned-words.validator.ts - Validator Function
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function bannedWordValidator(): ValidatorFn {

  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value
    if (!value) { return null; }
  }
}

If the input has no value, we return null because if we don't have any text, we are definitely not using any banned words. If we wanted our input to also be required, we would add a Validators.required into our array of validators on the form. We don't want to validate for 2 different types of errors from within the same validation function; by keeping them separate, we're also keeping our concerns separate, and so our code is easier to reuse, maintain, and test.

Building a Utility Class

We can now start writing the logic that checks our input's value against our list of banned words. For this example we are going to assume that the entire application has only one list of banned words. In order to display the banned word in user-facing hints and errors and make the logic more easily testable, we are going to create a utility class that we can use anywhere in our application, including in our validator function.

In a separate file we create a class and add our list of words as an array.

banned-words.utility.ts - Validator Function
import { Injectable } from '@angular/core';

@Injectable()
export class BannedWordsUtility {
  readonly bannedWordsList = [ 'JavaScript', 'Angular', 'Typescript' ];
}

Next we are going to create a function to generate a Regular Expression to check our value against. We'll start with an empty string, regexPattern = ''. We then loop over each of our banned words and add them to the empty string with a pipe in between each word. This will give a pipe-delineated string of our words. Then we can take our pattern and create a regex out of that by calling new RegEx(regexPattern, 'gim'). We pass in 'gim' as parameters after our pattern for global, insensitive, and multiline: global to check all matching instances instead of just the first, insensitive to ignore letter casing, and multiline to continue the check across line breaks.

banned-words.utility.ts - buildRegex
buildRegex(bannedWords: string[]): RegExp {

  //  dynamically build the regex
  let regexPattern = ''

  bannedWords.forEach((word, i) => { 
    //  if not first term add | (or)
    if (i !== 0) { regexPattern += '|'}
    regexPattern += word
  })
  //  regexPattern = 'JavaScript|Angular|Typescript'

  //  build regex from pattern
  const regex = new RegExp(regexPattern, 'gim')
  //  regex = /JavaScript|Angular|Typescript/gim

  return regex;
}

The output of the buildRegex function will give us a regular expression with which we can check that a string does not contain any of the banned words in our list, regardless of capitalization. We can now use this function to perform the test. In our utility class, we are going to build two different functions: one that checks whether or not the input contains any banned words and returns a boolean accordingly, and a second that returns a string containing all banned words used to be included in our error message for user guidance.

Let's start with the true/false check. Same as before, if the value is falsy, we will return false. Our input is empty, so it does not contain any banned words. If we do have a value, we use a RegExp.test() function to check our value and return whether that value contains a forbidden word.

banned-words.utility.ts - containsBannedWords
export class BannedWordsUtility {

  readonly bannedWordsList = [ 'javascript', 'angular', 'typescript' ];
  readonly regex: RegExp = this.buildRegex(this.bannedWordsList)

  buildRegex(bannedWords: string[]): RegExp { ... }

  containsBannedWords(value: string): boolean {
    if (!value) { return false }
    return this.regex.test(value);
  }
}

Returning a list of all banned words used works similarly, but instead of RegExp.test(), we use a String.match() function to return a formattable array of matches rather than a boolean.

banned-words.utility.ts - getUsedForbiddenWords
export class BannedWordsUtility {

  readonly bannedWordsList = [ 'javascript', 'angular', 'typescript' ];
  readonly regex: RegExp = this.buildRegex(this.bannedWordsList)

  buildRegex(bannedWords: string[]): RegExp { ... }

  containsBannedWords(value: string): boolean { ... }

  getUsedForbiddenWords(value: string): string {
    if(!value) { return value; }
    const matches = value.match(this.regex);
    return [ ...new Set(matches) ].toString().replace(/,/gim, ', ');
  }
}

The getUsedForbiddenWords function gets any matches from our list of banned words as an array of strings. We then deconstruct and recreate that array using the Set constructor to make sure that we only include unique values. The uniqueness will, unlike our regex, be case sensitive, so if our input includes both the words 'JavaScript' and 'javascript', both will be included in the new array. We then convert the new array of values to a string and replace commas with a comma and space to make it human- readable. We can now insert that list of words into the error message so that our user understands how to remedy the issue.

Finishing the Validator

We now have all of the pieces we need to create our validator in an easily reusable and testable format. Let's turn our attention back to the validator itself -- we last left it with a skeleton to which we stil need to add some logic.

banned-words.validator.ts - Validator Function
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function bannedWordValidator(): ValidatorFn {

  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value
    if (!value) { return null; }
  }
}

We can add our utility class and use our functions to perform the check.

banned-words.validator.ts - Importing the Utility Class

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { BannedWordsUtility } from '../utilities/banned-words/banned-words.utility';

export function bannedWordValidator(): ValidatorFn {

  const utility = new BannedWordsUtility();

  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value
    if (!value) { return null; }
    const containsBannedWords = utility.containsBannedWords(value);
  }
}

Now that we can tell whether the input contains banned words, we can return an error object that includes the initial value entered by the user and the string containing the list of banned words being utilized in the input. If the input does not include any forbidden words, we return null because there is no error to return -- the input is valid.

banned-words.validator.ts - Importing the Utility Class
return (control: AbstractControl): ValidationErrors | null => {
  const value = control.value
  if (!value) { return null; }
  const containsBannedWords = utility.containsBannedWords(value);

  return containsBannedWords ? {
    bannedWords: { value: control.value, bannedWords: utility.getUsedForbiddenWords(control.value) }
  } : null

}

We can now add our validator to the input itself by adding it to the validators array of the control.

banned-words.component.ts - Banned Words Validator
import { FormBuilder, Validators } from '@angular/forms';

@Component( ... )
export class BannedWordsComponent {

  form = this.fb.group({ comments: [null, [ Validators.maxLength(250),  bannedWordValidator() ]]})
  constructor( private fb: FormBuilder) { }

}

Adding Error Handling

At this point we will be able to programmatically tell whether our input is valid by checking this.form.get('comments').valid. However, this is not much help to our users. The final piece we need is a way to guide the user, and the best way to do that here is to add error handling.

We create an error div to include the appropriate messaging for each error type. The *ngIf on each error calls a showError function that checks if an error for a specific validator exists and returns true or false accordingly. To make sure that our users are not kept guessing which words are triggering the banned words validation, we'll display the bannedWords string from the error object, telling them exactly which words are causing the input to throw the error.

banned-words.component.ts - showError
@Component({ ... })
export class BannedWordsComponent {
  
  ... 

   showError(error: 'maxlength' | 'bannedWords'): boolean {
    const control = this.form.get('comments')
    return !!control?.hasError(error);
  }
}
banned-words.component.html - errors
<form [formGroup]="form" novalidate>

  <label for="comments">Comments</label>

  <textarea id="comments" #comments formControlName="comments" rows="5" aria-errormessage="errors">
  </textarea>

  <div class="errors" id="errors">
    <p *ngIf="showError('maxlength')">Comment must be 250 characters or less</p>
    <p *ngIf="showError('bannedWords')">
      Your comment contains the following forbidden words:
      <strong>{{ form.get('comments')?.getError('bannedWords')?.bannedWords }}</strong>
    </p>
  </div>

</form>

Closing Thoughts

It might be a lot more work to create front-end validation on the front end only to recheck it once the data is written to the database on the backend, but the ability to give immediate, clear instructions to users greatly improves the usability of any application -- and therefore, also improves that user's experience. I also alluded to, but did not show, how a utility class for the functions in our validator can make our code easier to test. A working copy of this code, including unit tests, can be found on github in my angular-unit-testing project in the BannedWordsComponent.

Happy Coding!

Consulting

Our expertise helps your team ramp up on new technologies or practices, or to fill short-term skills gaps.

Read more about Consulting with Andromeda

References

  • https://angular.io/api/forms/Validators
  • https://angular.io/api/forms/ValidatorFn