Validação assíncrona - AsyncValidatorFn Angular
Como criar um formulário reativo (Reactive Forms) com validação assíncrona (AsyncValidatorFn) do Angular.
 
            Antes de submeter os dados para o servidor, é importante garantir que todos os dados que o usuário informou estão de acordo com os requisitos do seu negócio, ou seja, os dados são consistentes e atendem às políticas do negócio. Por esta razão, a validação no client-side é importante e já foi alvo de estudo por aqui:
- https://consolelog.com.br/validacao-de-formulario-utilizando-o-reactiveformsmodule-do-angular-2/
- https://consolelog.com.br/validacao-formulario-ngmodel-angular/
- https://consolelog.com.br/como-criar-um-formulario-dinamico-angular/
- https://consolelog.com.br/validacao-componente-customizado-angular/
Neste texto será abordado como podemos realizar validações assíncronas. Por exemplo, o usuário está preenchendo um formulário de cadastro, um dos campos é o username. Ao preencher este campo a aplicação deve validar se o username está disponível para uso através da consulta à uma API.
Para realizar esta tarefa, iremos explorar um pouco do Reactive Forms e o AsyncValidatorFn do Angular.
Estruturando o projeto
Versões utilizadas para criar o projeto:
Angular CLI: 14.2.3
Node: 16.16.0Comandos para criar o projeto:
#Cria o diretório
mkdir consolelog-async-validator
#Entra no diretório
cd consolelog-async-validator
#Cria um novo projeto Angular
ng new projeto-async-validator
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? SCSSExecutando o projeto:
cd projeto-async-validator
npm run start
Configurando pacote de componentes PrimeNG
Com o projeto já configurado, poderíamos adicionar o @angular/material para criar um formulário com um design mais interessante, mas para variar um pouco vamos trabalhar com os componentes do PrimeNG. Para isto vamos adicionar os seguintes pacotes:
npm install primeng
npm install primeicons
npm install primeflexPrimeNG is a collection of rich UI components for Angular. All widgets are open source and free to use under MIT License. PrimeNG is developed by PrimeTek Informatics, a vendor with years of expertise in developing open source UI solutions. For project news and updates, please follow us on twitter and visit our blog.
Após a instalação é necessário adicionar no arquivo angular.json algumas referências  (linhas 29 - 32):
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "projeto-async-validator": {
      "projectType": "application",
      "schematics": {
        "@schematics/angular:component": {
          "style": "scss"
        }
      },
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/projeto-async-validator",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "inlineStyleLanguage": "scss",
            "assets": ["src/favicon.ico", "src/assets"],
            "styles": [
              "src/styles.scss",
              "node_modules/primeicons/primeicons.css",
              "node_modules/primeng/resources/themes/lara-light-blue/theme.css",
              "node_modules/primeng/resources/primeng.min.css",
              "node_modules/primeflex/primeflex.css"
            ],
            "scripts": []
          },
          "configurations": {},
          "defaultConfiguration": "production"
        },
        "serve": {},
        "extract-i18n": {},
        "test": {}
      }
    }
  },
  "cli": {}
}
Observação: no arquivo acima eu removi trechos que não são relevantes para esta configuração.
Criando um formulário com validação síncrona
Para criar o formulário vamos precisar importar alguns componentes do PrimeNG, ReactiveFormsModule e vou aproveitar para já importar o HttpClientModule que será usado mais à frente:
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { ButtonModule } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { AppComponent } from './app.component';
@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    ButtonModule,
    HttpClientModule,
    InputTextModule,
    ReactiveFormsModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
O formulário terá 2 campos com as seguintes regras:
- username:
 - campo obrigatório
 - de 3 a 10 letras minúsculas
- email:
 - campo obrigatório
 - deve ser um e-mail válido
O código ficará da seguinte forma:
import { Component } from '@angular/core';
import {
  FormControl,
  FormGroup,
  Validators,
} from '@angular/forms';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  formGroup: FormGroup = new FormGroup({
    username: new FormControl('', [
      Validators.required,
      Validators.pattern(/^[a-z]{3,10}$/),
    ]),
    email: new FormControl('', [
      Validators.required,
      Validators.email,
    ]),
  });
  get username() {
    return this.formGroup.get('username');
  }
  get email() {
    return this.formGroup.get('email');
  }
  postar() {
    if (this.formGroup.invalid) {
      return;
    }
    console.log(this.formGroup.value);
  }
}<h1>Cadastro</h1>
<form
  class="grid"
  [formGroup]="formGroup"
  (ngSubmit)="postar()">
  <div class="col-12 md:col-6">
      
    <!-- USERNAME -->
    <div class="p-inputgroup">
      <span class="p-inputgroup-addon">
        <i class="pi pi-user"></i>
      </span>
      <input
        autofocus
        formControlName="username"
        pInputText
        placeholder="Cadastre um username"
        type="text" />
    </div>
      
    <!-- USERNAME: MENSAGENS DE VALIDAÇÃO -->
    <!-- 
         Deixei o "username && username.errors"
         na condição abaixo para evitar o uso
         do safe navigation operator (?.) Fiz
         isto porque acredito que o código ficou
         mais legível, mas é só uma opinião pessoal!
    -->
    <div
      class="p-error"
      *ngIf="
        username &&
        username.errors &&
        username.invalid &&
        (username.dirty || username.touched)
      ">
      <div *ngIf="username.errors['required']">
        Username é obrigatório
      </div>
      <div *ngIf="username.errors['pattern']">
        Este campo deve conter apenas letras
        minúsculas de 3 a 10 caracteres
      </div>
    </div>
  </div>
    
  <!-- EMAIL -->
  <div class="col-12 md:col-6">
    <div class="p-inputgroup">
      <span class="p-inputgroup-addon">
        <i class="pi pi-at"></i>
      </span>
      <input
        formControlName="email"
        pInputText
        placeholder="Informe seu e-mail"
        type="email" />
    </div>
      
    <!-- EMAIL: MENSAGENS DE VALIDAÇÃO -->
    <div
      class="p-error"
      *ngIf="
        email &&
        email.errors &&
        email.invalid &&
        (email.dirty || email.touched)
      ">
      Por favor, informe um e-mail válido
    </div>
  </div>
  <div class="col-12">
    <button
      [disabled]="formGroup.invalid"
      label="Cadastrar"
      pButton
      type="submit"></button>
  </div>
</form>Executando o projeto é possível ver que o formulário está funcionando corretamente:

Agora vamos partir para a validação assíncrona do campo username. Quando o usuário digitar um valor no campo username, o frontend precisará consultar uma API para verificar se o valor informado está disponível para uso. Para simular este cenário vamos criar uma pequena API em Node.js.
API de validação
O código abaixo tem o único propósito de criar um ambiente em que possamos validar o username, então questões como sanitização, semântica, http status code ou quaisquer outros pontos não relevantes para este contexto não foram considerados.
Basicamente o código irá pegar na URL (query string) o campo username e irá verificar se o valor é igual a consolelog. Este valor, consolelog, será o único valor disponível para uso.
import { createServer } from "node:http";
import url from "node:url";
const server = createServer((req, res) => {
 const query = url.parse(req.url, true).query;
 res.write(
  JSON.stringify({
   disponivel: query.username === "consolelog",
  })
 );
 res.end();
});
server.listen(3000, () =>
 console.log("API pronta para uso")
);
{
 "name": "api-validacao",
 "version": "1.0.0",
 "description": "",
 "main": "index.js",
 "scripts": {
  "start": "node index.js"
 },
 "keywords": [],
 "type": "module",
 "author": "",
 "license": "ISC"
}
Utilizei o "type": "module" para indicar que o formato dos módulos será tratado como ES Modules. Por isto utilizei o import ao invés do require no arquivo index.js.
The"type"field defines the module format that Node.js uses for all.jsfiles that have thatpackage.jsonfile as their nearest parent.
Files ending with.jsare loaded as ES modules when the nearest parentpackage.jsonfile contains a top-level field"type"with a value of"module".
Link para mais detalhes.
Para testar a API usei o CURL no terminal, mas poderia ter usado um navegador qualquer:
$ npm run start
$ curl "localhost:3000?username=teste"
{"disponivel":false}                                
$ curl "localhost:3000?username=consolelog"
{"disponivel":true}Agora vamos para a validação assíncrona no frontend.
Criando o AsyncValidator
Para realizar a validação assíncrona do campo username vamos efetuar os seguintes passos:
- Criar uma service para se comunicar com a API: UserService
- Criar uma classe com um método estático para retornar um AsyncValidatorFn
- Alterar o AppComponentpara incluir a validação assíncrona
- Alterar o template do AppComponentpara exibir uma mensagem ao usuário
1-UserService
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {}
  get(username: string) {
    return this.http.get<{ disponivel: boolean }>(
      `http://localhost:3000/?username=${username}`
    );
  }
}
2-AppValidator
Abaixo criamos um método estático, usernameJaExiste, que recebe uma instância da UserService como parâmetro e retorna um AsyncValidatorFn. Deixei algumas explicações nos comentários ao longo do código abaixo:
import {
  AbstractControl,
  AsyncValidatorFn,
  ValidationErrors,
} from '@angular/forms';
import { map, Observable } from 'rxjs';
import { UserService } from './user.service';
export class AppValidator {
  static usernameJaExiste(
    userService: UserService
  ): AsyncValidatorFn {
    // *
    // * Repare que este método retorna
    // * um AsyncValidatorFn.
    // *
    // * No "return" abaixo temos uma função
    // * com a assinatura do AsyncValidatorFn,
    // * que é a seguinte:
    // *
    // * export declare interface AsyncValidatorFn {
    // *   (control: AbstractControl):
    // *     Promise<ValidationErrors | null> |
    // *     Observable<ValidationErrors | null>;
    // * }
    // *
    return (
      control: AbstractControl
    ): Observable<ValidationErrors | null> => {
      // *
      // * Conteúdo do método que é executado cada
      // * vez que o usuário digita algo
      // *
      // * Pega o valor do FormControl:
      const username = control.value;
      
      // * Retorna um Observable resultante
      // * da consulta à API:
      return userService.get(username).pipe(
        // * A API devolve um JSON no seguinte formato:
        // * { "disponivel": true|false }
        //
        // * Abaixo é retornado um novo valor
        // * dependendo do retorno da API.
        map((response) => {
          return response.disponivel
            ? // * Quando não há erros:
              null
            : // * Quando houver erros:
              { usernamejaexiste: username };
        })
      );
    };
  }
}3-AppComponent
No app.component.ts tiveram duas alterações:
- Injeção do UserServiceno construtor;
- Inclusão do validador assíncrono no terceiro argumento do construtor do FormControldousername;
import { Component } from '@angular/core';
import {
  FormControl,
  FormGroup,
  Validators,
} from '@angular/forms';
import { AppValidator } from './app.validator';
import { UserService } from './user.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  formGroup: FormGroup = new FormGroup({
    username: new FormControl(
      '',
      [
        Validators.required,
        Validators.pattern(/^[a-z]{3,10}$/),
      ],
      // Validações assíncronas:
      [AppValidator.usernameJaExiste(this.userService)]
    ),
    email: new FormControl('', [
      Validators.required,
      Validators.email,
    ]),
  });
  get username() {
    return this.formGroup.get('username');
  }
  get email() {
    return this.formGroup.get('email');
  }
  // Injeta a UserService para ser utilizada no
  // validador assíncrono do username
  constructor(private userService: UserService) {}
  postar() {
    if (this.formGroup.invalid) {
      return;
    }
    console.log(this.formGroup.value);
  }
}4-Template do AppComponent
No template também tiveram duas alterações:
- Inclusão da mensagem de erro quando o usernamenão está disponível para uso
- Inclusão do formGroup.pendingpara desabilitar o botãoCadastrarcaso oFormGroupainda esteja processando alguma validação, que no nosso caso será a validação assíncrona dousername:
 <!-- ...(trecho ocultado)... -->
 <!-- VALIDAÇÃO ASSÍNCRONA -->
<div *ngIf="username.errors['usernamejaexiste']">
  <strong>{{
    username.errors["usernamejaexiste"]
  }}</strong>
  não está disponível
</div>
<!-- ...(trecho ocultado)... -->
<div class="col-12">
  <button
    [disabled]="formGroup.invalid || formGroup.pending"
    label="Cadastrar"
    pButton
    type="submit"></button>
</div>
<!-- ...(trecho ocultado)... -->After asynchronous validation begins, the form control enters apendingstate. Inspect the control'spendingproperty and use it to give visual feedback about the ongoing validation operation.
fonte: https://angular.io/guide/form-validation#creating-asynchronous-validators
Problema com CORS: no meu ambiente de testes o frontend está rodando na porta 4200 e a API de validação na porta 3000. Neste caso vamos ter problemas de Cross-Origin Resouce Sharing (CORS), então para efetuar um bypass no seu navegador utilize uma das opções abaixo, lembrando que isto deve ser feito somente em ambiente de desenvolvimento:
- Chrome Windows: execute-o utilizando a seguinte opção:chrome.exe" --disable-web-security --disable-gpu --user-data-dir=~/chromeTemp.
- Chrome macOS: open -n -a /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --user-data-dir="/tmp/chrome_dev_test" --disable-web-security
- Safari: vá até a aba Developer e selecione a opção Disable Cross-Origin Restrictions
Executando o projeto temos o seguinte resultado:

No GIF acima é possível ver que o formulário está funcionando corretamente. Conforme o usuário preenche o campo username, o navegador efetua uma requisição para validar a disponibilidade na API.
O grande problema deste modelo é a cada tecla pressionada no campo username é disparada uma nova requisição. O ideal seria que essa requisição fosse disparada somente quando o usuário passar um determinado tempo sem digitar, por exemplo, disparar a requisição quando o intervalo entre as teclas pressionadas for maior que 500 milissegundos. Por sorte o RxJS oferece um operador chamado debounceTime que faz este trabalho.
Aplicando o debounceTime no AsyncValidator
Abaixo há uma modificação no método usernameJaExiste para incluir o debounceTime. Deixei comentários ao longo do código para facilitar o entendimento:
import {
  AbstractControl,
  AsyncValidatorFn,
  ValidationErrors,
} from '@angular/forms';
import {
  BehaviorSubject,
  debounceTime,
  map,
  Observable,
  switchMap,
  take,
} from 'rxjs';
import { UserService } from './user.service';
export class AppValidator {
  static usernameJaExiste(
    userService: UserService
  ): AsyncValidatorFn {
    const subject = new BehaviorSubject<string>('');
    const output = subject.asObservable().pipe(
      // Só emite um valor quando o intervalo
      // entre as teclas pressionadas for maior
      // que 500ms
      debounceTime(500),
      // Retorna um observable que emite apenas
      // o primeiro valor e então aplica o
      // "completed" do Observable
      take(1),
      // "Troca" o valor emitido, que é o que o
      // usuário digitou, por um novo observable
      // resultante da consulta à API
      switchMap((username: string) =>
        userService.get(username).pipe(
          // Como a API não retorna o "username",
          // utilizamos o "map" para modificar a
          // resposta obtida na consulta à API
          map((apiResponse) => ({
            ...apiResponse,
            username,
          }))
        )
      ),
      map((response) => {
        return response.disponivel
          ? // Se o usuário estiver disponível:
            null
          : // Caso o usuário não esteja disponível,
            // emitimos o erro:
            ({
              usernamejaexiste: response.username,
            } as ValidationErrors);
      })
    );
    return (
      control: AbstractControl
    ): Observable<ValidationErrors | null> => {
      const username = control.value;
      // Sempre que o usuário digitar algo,
      // passamos o valor para o "subject"
      subject.next(username);
      return output;
    };
  }
}

Após o ajuste no validador assíncrono, é possível perceber que as requisições à API de validação foram reduzidas e são feitas somente quando o intervalo entre a penúltima e a última tecla pressionada no campo username for maior que 500ms.
Agora temos um formulário completo, com validações síncronas e assíncronas!
Considerações
 Eventualmente sua fonte de validação será assíncrona, como vimos no texto acima, consequentemente o AsyncValidatorFn será um ótimo recurso para se usar.
Um outro ponto relevante que podemos destacar, é o aumento da complexidade do código quando aplicamos o debounceTime. Por outro lado, esta alteração entregou um belo ganho em performance ao reduzir a quantidade de chamadas ao backend.
Links interessantes:
 
             
                             
             
             
            