angulartypescriptangular-reactive-formsnanangular-custom-validators

Validate against NaN values for <input type="number">


I am creating a form using Angular Reactive forms, and for UX reasons I am trying to disable the form's submit when certain invalid values are inserted in an <input type="number"> field, for example negative numbers or non-integer numbers.

The problem is that when I insert a value that even if allowed by <input type="number"> would usually evaluate to NaN, like, for example, the letter e alone, the minus or plus sign without a number following it, or even a number too big to be represented by a TypeScript number, instead evaluates to null when accessing the value via control.value or control.getRawValue().

EDIT: The form also needs to be submittable when the input field is empty. This is sadly a non-negotiable requirement, and I also cannot use a default value like 0 for the field.

This is a minimum reproducible example for what I tried (it only tries to block NaN values).

app.component.ts

import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, ValidatorFn } from '@angular/forms';

@Component({
  selector: 'app-root',
  template: `
    <form [formGroup]="formGroup" (ngSubmit)="print()">
      <input type="number" name="control" id="control" formControlName="control" >
      <button type="submit" [disabled] ="formGroup.invalid">print</button>
    </form>
  `
})
export class AppComponent {
  title = 'ReactiveFormNumberControl';

  formGroup

  constructor(private _fb: FormBuilder) {
    this.formGroup = this._fb.group({
      control: this._fb.control<number | null>(null, {
        validators: [noNaNValidator]
      })
    })
  }

  print = () => {
    console.log(this.formGroup.value)
  }
}

const noNaNValidator: ValidatorFn = (
  control: AbstractControl<number | null>,
) => {
  const value = control.value

  if (Number.isNaN(value)) {
    // Value is null and not NaN, so this is never reached because Number.isNaN(null) is false,
    // the control (and group) is marked valid and the submit button is not disabled
    return {
      invalidValue: {
        value: value
      },
    }
  }
  return null
}

Solution

  • While looking at Angular issues related to this on GitHub, more precisely Issue #2962, I found a workaround for this, leveraging HTML5's badInput property present present in some types of <input> elements, number included.

    I've wrote a custom validator directive, similar to the one shown in this comment on Angular Issue #2962, but unlike that one the directive I've wrote has to be explicitly applied, and for now can only be applied to <input type="number"> fields (but this should be easy to change if needed, by changing the directive's selector).

    bad-input-validator.directive.ts

    import { Directive, ElementRef } from '@angular/core';
    import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
    
    @Directive({
      selector: 'input[type=number][appBadInputValidator]',
      standalone: true,
      providers: [{
        provide: NG_VALIDATORS,
        useExisting: BadInputValidatorDirective,
        multi: true,
      }]
    })
    export class BadInputValidatorDirective implements Validator {
    
      private onChangeCallback?: () => void;
    
      constructor(private _element: ElementRef) { }
    
      validate(_: AbstractControl): ValidationErrors | null {
        const inputElement = this._element.nativeElement
        return inputElement.validity.badInput ? { 'badInput': true } : null
      }
    
      registerOnValidatorChange?(fn: () => void): void {
        this.onChangeCallback = fn
      }
    }
    

    This can probably be improved somehow, but for now it does what I need to. When I apply the appBadInputValidator to an <input type="number"> element, and I input a value that would parse to NaN, the FormControl and FormGroup get marked as invalid and the submit button is correctly disabled.

    This directive works similar Angular.JS's form validation, that kept track of badInput, but this behavior is currently not present in Angular, at least as of version 16.2.6.

    Big thanks to amc1804 on GitHub for posting a solution.