Técnicas Avançadas para Validação de Formulários em Angular

Validações dentro e fora do ControlValueAccessor

Provavelmente, você já usou validações de formulário em Angular. Neste artigo, vou mostrar como elas funcionam e como criar a sua própria validação, mas já existe muito conteúdo ensinando isso.

O que eu quero fazer aqui é dar um passo adiante. Em vez de apenas ensinar como usar validações por fora, vou te ensinar como usá-las por dentro.

LinkValidações de Angular

Vamos começar com o básico. Quando você cria um FormControl,você pode opcionalmente fornecer um Array de validadores. Alguns validadores são síncronos e outros são assíncronos.

Alguns precisaram ser implementados pela equipe do Angular para cumprir com a especificação HTML, tais como [min], [max], [required], [email], etc. Esses podem ser encontrados na biblioteca de formulários do Angular.

TypeScript
import { Validators } from '@angular/forms';

new FormControl(5, [Validators.min(0), Validators.max(10)]);

new FormControl('[email protected]', [Validators.required, Validators.email]);

LinkReactive vs Template

Se você declarar um input com o atributo "required" ao usar o FormsModule, o Angular irá transformar esse input em um ControlValueAccessor, criar um FormControlcom o validador "required" e anexar o FormControl ao ControlValueAccessor

HTML
<input type="text" name="email" [(ngModel)]="someObject.email" required />

Isso tudo acontece nos bastidores, e sem segurança na tipagem. É por isso que evito o FormsModule, é muito mágico e pouco tipado para o meu gosto, prefiro trabalhar com algo mais explícito, e é aí que entra o ReactiveFormsModule.

Em vez de usar a sintaxe de banana que faz toda aquela mágica para você, com o ReactiveFormsModule, você:

  1. Instancia seu FormControl manualmente;
  2. Anexa os validadores manualmente;
  3. Escuta as mudanças manualmente;
  4. E anexa o ControlValueAccessor de uma forma semi-manual.

Com exceção dessa última etapa, tudo isso é feito em TypeScript, não no template em HTML. E isso oferece muito mais segurança de tipagem. Não é perfeito, pois, trata os valores internos como any, mas eles estão trabalhando para mudar isso e também existe uma boa biblioteca para contornar esse problema no meio tempo.

LinkValidatorFn

Chega de teoria, vamos para o código.

No último artigo, implementamos um input de data. Mas, como mencionei no final do artigo, quero alterá-lo para que aceite apenas dias úteis. Isso significa:

  • Sem finais de semana
  • Sem feriados
  • Sem datas inexistentes (como 31 de fevereiro).

Vamos começar com os finais de semana. Eu tenho uma função simples que recebe uma Date e retorna um booleano indicando se essa data é um fim de semana.

TypeScript
enum WeekDay {
  Sunday = 0,
  Monday,
  Tuesday,
  Wednesday,
  Thursday,
  Friday,
  Saturday
}

export const isWeekend = (date: Date): boolean => {
  const weekDay = date.getDay();
  switch (weekDay) {
    case WeekDay.Monday:
    case WeekDay.Saturday:
      return true;
    default:
      return false;
  }
};

Está bom, mas precisamos de uma função com uma assinatura diferente para que isso funcione. O que o Angular espera de uma ValidatorFn é que ela retorne null se estiver tudo bem, ou um objeto se algo estiver errado.

As propriedades do objeto retornado são identificadores para os erros. Por exemplo: se a data for um final de semana, posso retornar um objeto com a propriedade "weekend" definida como true. Isso significa que o FormControl agora tem um erro, chamado weekend, e seu valor é true. Se eu fizer FormControl.getError('weekend'), obtenho true. E se eu fizer FormControl.valid, obtenho false, porque ele tem um erro, então não é válido.

Você pode atribuir qualquer valor à propriedade do erro. Por exemplo, você poderia atribuir Saturday, e quando você chamasse FormControl.getError('weekend'), você obteria Saturday.

A propósito, a ValidatorFn não recebe o valor como parâmetro, ela recebe o AbstractControl que envolve o valor. Um AbstractControl pode ser um FormControl, um FormArray ou um FormGroup, você só tem que extrair o valor dele antes de fazer sua validação.

TypeScript
export const weekendValidator: ValidatorFn = (
  control: AbstractControl
): null | { weekend: true } => {
  const value = control.value;
  if (isDate(value) === false) return null;
  if (isWeekend(value)) return { weekend: true };
  return null;
};

Além disso, não se esqueça de que o valor pode ser null ou algo diferente de uma Date, por isso, é importante lidar com essas exceções. Para essa função de validador de fim de semana, vou simplesmente ignorar se o valor não for uma data.

Ok, agora que está feito, você só tem que usá-lo como você faria com Validators.required.

TypeScript
export class AppComponent {
  public readonly dateControl = new FormControl(new Date(), [weekendValidator]);
}

LinkAsyncValidatorFn

Agora vamos abordar o validador de feriados.

Este é um caso diferente porque precisaremos acessar uma API externa para consultar se a data é feriado ou não. E isso significa que não é síncrono, portanto, não podemos retornar null ou um objeto. Precisaremos usar Promises ou Observables.

Eu não sei você, mas eu prefiro usar Promises quando possível. Eu gosto de Observables e sei muito sobre eles, mas eles são desconfortáveis ​​para muitas pessoas. Acredito que Promises são muito mais amplamente compreendidas e, em geral, mais simples.

O mesmo se aplica para fetch versus o HTTPClient do Angular. Se eu não preciso de renderização do lado do servidor, eu deixo o HTTPClient de lado e uso fetch.

Então, eu fiz uma função que recebe uma Date e retorna uma Promise de um boolean, indicando se essa data é um feriado. Para fazer isso funcionar, estou usando uma API gratuita que me dá uma lista de feriados para uma determinada data.

Estou usando o plano gratuito deles, então estou limitado a uma solicitação por segundo e apenas feriados deste ano. Mas, para os nossos propósitos, isso será suficiente.

TypeScript
export const isHoliday = async (date: Date): Promise<boolean> => {
  const year = date.getFullYear();
  const month = date.getMonth() + 1;
  const day = date.getDate();

  const currentYear = new Date().getFullYear();
  if (year < currentYear) {
    console.warn(
      `We're using a free API plan to see if the date is a holiday and in this free plan we can only check for dates in the current year`
    );
    return false;
  }

  // This is to make sure I only make one request per second
  await holidayQueue.push();

  const queryParams = new URLSearchParams({
    api_key: environment.abstractApiKey,
    country: 'US',
    year: year.toString(),
    month: month.toString(),
    day: day.toString()
  });

  const url = `https://holidays.abstractapi.com/v1/?${queryParams.toString()}`;
  const rawRes = await fetch(url);
  const jsonRes = await rawRes.json();

  return (
    isArray(jsonRes) &&
    isEmpty(jsonRes) === false &&
    // They return multiple holidays and I only care if it's a national one
    jsonRes.some((holiday) => holiday.type === 'National')
  );
};

Assim como nosso caso anterior, esta assinatura não serve. O que o Angular espera de um AsyncValidatorFn é que ele receba um AbstractControl e retorne null ou um objeto envolto em uma Promise ou um Observable.

TypeScript
export const holidayValidator: AsyncValidatorFn = async (
  control: AbstractControl
): Promise<null | { holiday: true }> => {
  const value = control.value;
  if (isDate(value) === false) return null;
  if (await isHoliday(value)) return { holiday: true };
  return null;
};

Novamente, não se esqueça de lidar com exceções. Por exemplo, se o valor não for uma Date.

Agora podemos usá-lo em nosso FormControl. Observe que os AsyncValidatorFns são o terceiro parâmetro do FormControl, não o segundo.

TypeScript
export class AppComponent {
  public readonly dateControl = new FormControl(
    new Date(),
    [weekendValidator],
    [holidayValidator]
  );
}

LinkValidador

Até aqui tudo bem, agora só resta uma verificação: verificar se a data existe.

Eu tenho uma função aqui que recebe o dia, mês e ano, e retorna um booleano indicando se essa data existe. É uma função bastante simples. Eu crio uma Date a partir dos valores fornecidos, e verifico se o ano, mês e dia da data recém-criada são os mesmos usados ​​para construí-la.

TypeScript
export const dateExists = (
  year: number,
  month: number,
  day: number
): boolean => {
  const date = new Date(year, month - 1, day);
  return (
    date.getFullYear() === year &&
    date.getMonth() === month - 1 &&
    date.getDate() === day
  );
};

Você pode estar pensando que isso é tão óbvio que é praticamente inútil. Para você eu digo: você não conhece o construtor da Date, ele é complicado...

Veja, você pode pensar que instanciar uma Date com 31 de fevereiro geraria um erro. Mas não dá erro, ele gera 3 de março (por favor, ignore anos bissextos neste exemplo).

TypeScript
new Date(2021, 1, 31);
//=> March 03, 2021

Por conta disso, não conseguimos pegar uma Date e dizer se é uma data existente ou não, porque não sabemos qual dia, mês e ano foram usados para instanciá-la. Mas se você tiver essa informação, você pode tentar criar uma Date e verificar se o dia, mês e ano da Date criada é o que você esperava.

Infelizmente, nosso componente de data não nos dá essa informação, ele apenas expõe a Date já instanciada. Poderíamos fazer alguns hacks aqui, como criar um método público no nosso componente que nos desse essas propriedades e então pegaríamos a instância do componente e faríamos a nossa validação.

Mas isso parece errado, estaríamos expondo detalhes internos do nosso componente e isso nunca é uma boa ideia, ele deveria ser uma caixa preta. Deveria haver uma solução melhor, e sim, há uma. Podemos fazer a validação de dentro do componente.

Existe uma interface chamada Validator exportada na biblioteca de formulários do Angular, e ela é muito semelhante ao ControlValueAccessor. Você implementa a interface em seu componente e fornece o próprio componente em um token específico. NG_VALIDATORS, neste caso.

Para estar em conformidade com a interface Validator, você só precisa de um único método chamado validate(). Este método é uma ValidatorFn. Ele recebe um AbstractControl e retorna null ou um objeto com os erros ocorridos.

Mas como estamos dentro do componente, nós não precisamos do AbstractControl, podemos extrair o valor diretamente.

TypeScript
public validate(): { invalid: true } | null {
  if (
    this.dayControl.invalid ||
    this.monthControl.invalid ||
    this.yearControl.invalid
  )
    return { invalid: true };

  const day = this.dayControl.value;
  const month = this.monthControl.value;
  const year = this.yearControl.value;
  if (dateExists(year, month, day)) return { invalid: true };

  const date = new Date(year, month - 1, day);
  if (isWeekend(date)) return { weekend: true };
  if (await isHoliday(date)) return { holiday: true };

  return null;
}

Isso funciona exatamente como as ValidatorFns que estávamos passando para o FormControl, mas funciona de dentro. E tem dois benefícios:

  1. Primeiro, seria um pesadelo implementar esta validação de fora do componente;
  2. E dois, não precisamos declarar as validações toda vez que criamos um FormControl, elas estarão presentes no componente por padrão.

Esse segundo benefício me atrai bastante. Penso que faz todo o sentido que o nosso componente seja responsável pela sua própria validação. Se quiséssemos personalizá-lo, poderíamos criar @Inputs, por exemplo, [holiday]="true" significa que aceitamos que a data seja um feriado, e que esta validação deve ser ignorada.

Não vou implementar essas personalizações porque eles estão fora do escopo deste artigo, mas agora você sabe como eu faria isso.

Como eu disse, penso que faz todo sentido que o nosso componente seja responsável pela sua própria validação. Então, vamos trazer nosso outro validador síncrono para dentro também.

TypeScript
public validate(): {
  invalid?: true;
  weekend?: true;
} | null {
  if (
    this.dayControl.invalid ||
    this.monthControl.invalid ||
    this.yearControl.invalid
  )
    return { invalid: true };

  const day = this.dayControl.value;
  const month = this.monthControl.value;
  const year = this.yearControl.value;
  if (dateExists(year, month, day)) return { invalid: true };

  const date = new Date(year, month - 1, day);
  if (isWeekend(date)) return { weekend: true };

  return null;
}

LinkAsyncValidator

A última coisa que falta é trazer nosso validador assíncrono para dentro. E isso vai ser fácil, só precisamos de alguns ajustes.

Em vez de implementar a interface Validator, vamos implementar a interface AsyncValidator. E em vez de fornecer nosso componente no token NG_VALIDATORS, vamos fornecer no token NG_ASYNC_VALIDATORS.

Agora, nosso método validate() deve ser uma AsyncValidatorFn, então precisaremos envolver seu retorno em uma Promise.

TypeScript
public async validate(): Promise<{
  invalid?: true;
  holiday?: true;
  weekend?: true;
} | null> {
  if (
    this.dayControl.invalid ||
    this.monthControl.invalid ||
    this.yearControl.invalid
  )
    return { invalid: true };

  const day = this.dayControl.value;
  const month = this.monthControl.value;
  const year = this.yearControl.value;
  if (dateExists(year, month, day)) return { invalid: true };

  const date = new Date(year, month - 1, day);
  if (isWeekend(date)) return { weekend: true };
  if (await isHoliday(date)) return { holiday: true };

  return null;
}

Agora que todos os validadores estão implementados no componente, podemos removê-los de fora.

TypeScript
export class AppComponent {
  public readonly dateControl = new FormControl(new Date());
}

LinkConclusion

Vou deixar um link para o repositório nas referências abaixo.

Tenha um ótimo dia e nos vemos em breve!

LinkReferências

  1. Repositório GitHub
  2. Introdução à ControlValueAccessors Canal Lucas Paganini
  3. Pull request para fazer formulários Angular estritamente tipados GitHub
  4. Biblioteca para formulários tipados por enquanto npm
  5. Artigo que explica como foi criada a biblioteca de formulários tipados Indepth
  6. Validação de formulários Angular por fora Angular docs
  7. Validação Angular por dentro Angular docs
  8. Validação assíncrona Angular por dentro Angular docs

Assine a nossa Newsletter e seja avisado quando eu lançar um curso, postar um vídeo ou escrever um artigo.

Campo obrigatório
Campo obrigatório