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.
Validaçõ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.
import { Validators } from '@angular/forms';
new FormControl(5, [Validators.min(0), Validators.max(10)]);
new FormControl('test@example.com', [Validators.required, Validators.email]);
TypeScriptimport { Validators } from '@angular/forms';
new FormControl(5, [Validators.min(0), Validators.max(10)]);
new FormControl('test@example.com', [Validators.required, Validators.email]);
Reactive 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 FormControl
com o validador "required" e anexar o FormControl
ao ControlValueAccessor
<input
type="text"
name="email"
[(ngModel)]="someObject.email"
required />
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ê:
- Instancia seu
FormControl
manualmente; - Anexa os validadores manualmente;
- Escuta as mudanças manualmente;
- 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.
ValidatorFn
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.
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;
}
};
TypeScriptenum 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.
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;
};
TypeScriptexport 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
.
export class AppComponent {
public readonly dateControl = new FormControl(new Date(), [weekendValidator]);
}
TypeScriptexport class AppComponent {
public readonly dateControl = new FormControl(new Date(), [weekendValidator]);
}
AsyncValidatorFn
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 Promise
s ou Observable
s.
Eu não sei você, mas eu prefiro usar Promise
s quando possível. Eu gosto de Observable
s e sei muito sobre eles, mas eles são desconfortáveis para muitas pessoas. Acredito que Promise
s 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.
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')
);
};
TypeScriptexport 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
.
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;
};
TypeScriptexport 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 AsyncValidatorFn
s são o terceiro parâmetro do FormControl
, não o segundo.
export class AppComponent {
public readonly dateControl = new FormControl(
new Date(),
[weekendValidator],
[holidayValidator]
);
}
TypeScriptexport class AppComponent {
public readonly dateControl = new FormControl(
new Date(),
[weekendValidator],
[holidayValidator]
);
}
Validador
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.
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
);
};
TypeScriptexport 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).
new Date(2021, 1, 31);
//=> March 03, 2021
TypeScriptnew 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.
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;
}
TypeScriptpublic 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 ValidatorFn
s que estávamos passando para o FormControl
, mas funciona de dentro. E tem dois benefícios:
- Primeiro, seria um pesadelo implementar esta validação de fora do componente;
- 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 @Input
s, 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.
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;
}
TypeScriptpublic 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;
}
AsyncValidator
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
.
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;
}
TypeScriptpublic 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.
export class AppComponent {
public readonly dateControl = new FormControl(new Date());
}
TypeScriptexport class AppComponent {
public readonly dateControl = new FormControl(new Date());
}
Conclusion
Vou deixar um link para o repositório nas referências abaixo.
Tenha um ótimo dia e nos vemos em breve!
Referências
- Repositório GitHub
- Introdução à ControlValueAccessors Canal Lucas Paganini
- Pull request para fazer formulários Angular estritamente tipados GitHub
- Biblioteca para formulários tipados por enquanto npm
- Artigo que explica como foi criada a biblioteca de formulários tipados Indepth
- Validação de formulários Angular por fora Angular docs
- Validação Angular por dentro Angular docs
- Validação assíncrona Angular por dentro Angular docs