Voltar ao Blog
Angular26 de junho de 202612 min de leitura

Formulários Complexos em Angular. Lições de Apps Financeiros

DR

Doug Ribeiro

12 min de leitura

Formulários financeiros são uma categoria específica de problema de UI para a qual a maioria dos tutoriais Angular não te prepara. Um tutorial típico mostra um formulário de contato com um campo de nome e um campo de email. Uma aplicação de empréstimo real tem seções condicionais que aparecem com base no tipo de emprego, regras de validação cross-field que referenciam múltiplos inputs simultaneamente, validators assíncronos que chamam services do back-end para verificar elegibilidade em tempo real, e arrays de campos dinâmicos onde o usuário pode adicionar ou remover co-mutuários ou fontes de renda.

Ao longo dos anos, do portal de ativação de seguros que construí na BNP Paribas Cardif à plataforma digital de solicitação de empréstimo em que trabalhei no BMO, desenvolvi um conjunto de padrões para Angular Reactive Forms que uso repetidamente. Este post documenta eles da forma que eu gostaria que alguém tivesse documentado para mim. Os exemplos de código são ilustrativos, escritos para este post e não retirados do codebase de nenhum empregador.

A Base: Typed Reactive Forms

Antes de qualquer coisa, se você está no Angular 14 ou superior e não está usando reactive forms com tipagem estrita, está deixando uma quantidade significativa de segurança de lado. Em aplicações financeiras onde um valor incorreto pode ter consequências monetárias no mundo real, type safety nos seus formulários não é opcional -- é um requisito.

typescript
interface LoanApplicationForm {
  applicant: FormGroup<{
    firstName: FormControl<string>;
    lastName: FormControl<string>;
    dateOfBirth: FormControl<string | null>;
    annualIncome: FormControl<number | null>;
    employmentType: FormControl<'employed' | 'self-employed' | 'retired' | null>;
  }>;
  loanDetails: FormGroup<{
    requestedAmount: FormControl<number | null>;
    termMonths: FormControl<number | null>;
    purpose: FormControl<string | null>;
  }>;
  coApplicants: FormArray<FormGroup<CoApplicantForm>>;
}

const form = new FormGroup<LoanApplicationForm>({
  applicant: new FormGroup({
    firstName: new FormControl<string>('', { nonNullable: true }),
    lastName: new FormControl<string>('', { nonNullable: true }),
    dateOfBirth: new FormControl<string | null>(null),
    annualIncome: new FormControl<number | null>(null),
    employmentType: new FormControl<'employed' | 'self-employed' | 'retired' | null>(null),
  }),
  loanDetails: new FormGroup({
    requestedAmount: new FormControl<number | null>(null),
    termMonths: new FormControl<number | null>(null),
    purpose: new FormControl<string | null>(null),
  }),
  coApplicants: new FormArray<FormGroup<CoApplicantForm>>([]),
});

A opção nonNullable no FormControl é particularmente importante para campos que devem sempre ter um valor. Ela significa que chamar reset() no controle retorna ao valor inicial ao invés de null, o que previne uma classe inteira de erros de runtime em workflows financeiros onde um campo de valor nulo poderia se propagar para a lógica de cálculo.

Formulários Multi-Step: Isolamento de Estado vs. Formulário Unificado

Existem duas abordagens principais para formulários multi-step em Angular. Você pode manter um FormGroup grande que representa a aplicação inteira, ou pode manter formulários separados por step e compor o objeto final de submissão no final. Cada abordagem tem trade-offs reais.

Eu geralmente prefiro a abordagem de FormGroup unificado para aplicações financeiras por um motivo principal: validação cross-step. Aplicações de empréstimo frequentemente têm regras de validação que abrangem steps: o valor do empréstimo solicitado no step dois é validado contra a renda informada no step um. Se seus formulários são isolados por step, implementar isso de forma limpa é difícil. Se você tem um único reactive form, o validator cross-step é apenas um validator cross-field padrão.

A abordagem unificada exige disciplina com seu form service. Eu isolo a instância do formulário em um service injetável que persiste entre os componentes de step, exponho getters tipados para cada seção, e garanto que os componentes de step não guardem nenhum estado do formulário. O componente apenas renderiza e lida com a interação do usuário -- o estado do formulário vive inteiramente no service.

Validação Dinâmica Baseada em Input do Usuário

É aqui que formulários financeiros ficam genuinamente complexos. As regras de validação para um autônomo são completamente diferentes das regras de validação para um assalariado. Aposentados precisam de campos de documentação de pensão que são invisíveis e irrelevantes para outros tipos de emprego. A abordagem ingênua é mostrar e esconder campos e também adicionar e remover validators manualmente. Isso leva a bugs sutis onde validators ficam ativos em campos escondidos.

Meu padrão preferido é chamar setValidators no grupo visível, clearValidators nos escondidos, e sempre terminar com updateValueAndValidity, combinado com o mecanismo de disable do Angular:

typescript
private syncValidatorsToEmploymentType(employmentType: string | null): void {
  const selfEmployedGroup = this.form.get('applicant.selfEmployedDetails');
  const pensionGroup = this.form.get('applicant.pensionDetails');

  if (employmentType === 'self-employed') {
    selfEmployedGroup?.enable();
    selfEmployedGroup?.setValidators(selfEmployedValidators);
    pensionGroup?.disable();
    pensionGroup?.clearValidators();
  } else if (employmentType === 'retired') {
    pensionGroup?.enable();
    pensionGroup?.setValidators(pensionValidators);
    selfEmployedGroup?.disable();
    selfEmployedGroup?.clearValidators();
  } else {
    selfEmployedGroup?.disable();
    selfEmployedGroup?.clearValidators();
    pensionGroup?.disable();
    pensionGroup?.clearValidators();
  }

  selfEmployedGroup?.updateValueAndValidity();
  pensionGroup?.updateValueAndValidity();
}

// Subscribe to employment type changes in the form service constructor
this.form.get('applicant.employmentType')!.valueChanges
  .pipe(distinctUntilChanged(), takeUntilDestroyed())
  .subscribe(value => this.syncValidatorsToEmploymentType(value));

A chamada disable() é criticamente importante. Controles de formulário desabilitados são excluídos do form.value, o que significa que campos irrelevantes são automaticamente removidos do payload sem qualquer limpeza manual. Se você precisa ler todos os valores independente do estado desabilitado -- por exemplo, para repopular um rascunho -- use getRawValue(), que inclui controles desabilitados. Na lógica de submissão, sempre use form.value para que seções desabilitadas nunca cheguem ao servidor.

Validators Cross-Field

Um validator cross-field é um validator anexado a um FormGroup ao invés de um FormControl individual. Ele recebe o grupo inteiro como argumento e pode comparar quaisquer valores dentro dele. Para aplicações financeiras, isso é essencial.

typescript
// Illustrative affordability check. The 0.44 ceiling is loosely inspired by CMHC's 44% TDS
// limit for insured mortgages. It is not a TDS calculation: real TDS is housing costs plus
// every other debt payment over gross income, and it applies to mortgage insurance, not to a
// generic consumer loan. Treat the number as a product rule you configure.
export function loanAffordabilityValidator(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const annualIncome = group.get('annualIncome')?.value as number | null;
    const requestedAmount = group.get('requestedAmount')?.value as number | null;
    const termMonths = group.get('termMonths')?.value as number | null;

    if (!annualIncome || !requestedAmount || !termMonths) {
      return null; // Let individual field validators handle null cases
    }

    // Straight-line split of the principal, no interest. A real payment runs an amortization
    // formula with the rate, which is the back end's job here.
    const estimatedMonthlyPayment = requestedAmount / termMonths;
    const monthlyIncome = annualIncome / 12;
    const paymentToIncomeRatio = estimatedMonthlyPayment / monthlyIncome;

    if (paymentToIncomeRatio > 0.44) {
      return {
        affordability: {
          ratio: paymentToIncomeRatio,
          maxAllowed: 0.44,
          message: 'Monthly payment exceeds the configured payment-to-income limit',
        },
      };
    }

    return null;
  };
}

// Applied to the parent group, not an individual control
const loanApplicationGroup = new FormGroup(
  { ...controls },
  { validators: [loanAffordabilityValidator()] }
);

Exibir erros de validação cross-field exige um padrão de template ligeiramente diferente. O erro vive no grupo, não em nenhum controle individual, então você acessa com form.get('groupName')?.errors?.['affordability'] ao invés dos errors do controle individual.

Ciclo de Vida do Estado do Formulário

Estados limposPristine. Intocado desde o carr…Dirty. O usuário digitouValid. Todos os validators pass…Submitted. O formulário foi env…Estados de erroInvalid. A validação falhouPending. Validator assíncrono r…Touched. O campo perdeu o focoDisabled. Campo bloqueadoVS

Validators Assíncronos: Verificações de Elegibilidade no Backend

Algumas regras de validação não podem ser computadas no client-side. Esse SIN é válido e ainda não está registrado? Esse endereço de propriedade está em uma região que o credor atende? O requerente passa em uma regra de elegibilidade que só o back-end conhece? Esses exigem validators assíncronos que chamam services do back-end.

Validators assíncronos introduzem latência, e você precisa lidar com essa latência cuidadosamente para evitar uma experiência frustrante para o usuário. Meu padrão sempre inclui debouncing, um indicador de estado pendente, e tratamento de erro para quando o service falha:

typescript
export function sinValidatorFactory(eligibilityService: EligibilityService): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    const value = control.value as string;

    if (!value || value.length !== 9) {
      return of(null);
    }

    return timer(400).pipe(
      switchMap(() =>
        eligibilityService.validateSIN(value).pipe(
          map(result => {
            if (!result.valid) {
              return { sinInvalid: { message: result.message } };
            }
            if (result.duplicate) {
              return { sinDuplicate: true };
            }
            return null;
          }),
          catchError(() => of({ sinServiceUnavailable: true }))
        )
      )
    );
  };
}

// In the template, check the pending state
// <span *ngIf="sinControl.pending">Verifying...</span>
// <span *ngIf="sinControl.errors?.['sinServiceUnavailable']">Verification unavailable, please continue</span>

O timer(400) antes da chamada de API funciona como debounce. Sem isso, cada tecla pressionada dispararia uma requisição ao backend. O catchError que retorna of({ sinServiceUnavailable: true }) é importante: uma queda do service vira um erro específico que o template consegue explicar. Tenha em mente que qualquer erro retornado por um validator assíncrono marca o controle como inválido. Se uma queda não deve bloquear o usuário de completar o formulário, a lógica de submissão precisa tratar sinServiceUnavailable como um aviso e não como um bloqueio, e a verificação definitiva roda server-side no momento da submissão.

Padrões de FormArray para Campos Dinâmicos

Aplicações de empréstimo frequentemente permitem que usuários adicionem múltiplas fontes de renda, múltiplas propriedades ou múltiplos co-mutuários. FormArray é o mecanismo Angular para isso, mas gerenciar FormArrays tipados com grupos aninhados complexos exige alguns padrões cuidadosos.

typescript
interface CoApplicantForm {
  firstName: FormControl<string>;
  lastName: FormControl<string>;
  annualIncome: FormControl<number | null>;
  relationship: FormControl<string | null>;
}

@Injectable({ providedIn: 'root' })
export class LoanFormService {
  // The unified LoanApplicationForm from the first sample, built once and kept here
  readonly form = buildLoanApplicationForm();

  // The array is a branch of the unified form, not a second form standing next to it. That is
  // what keeps a cross-step validator able to read co-applicant income alongside the applicant's.
  get coApplicants(): FormArray<FormGroup<CoApplicantForm>> {
    return this.form.controls.coApplicants;
  }

  addCoApplicant(): void {
    if (this.coApplicants.length >= 3) return; // Product rule, not a regulation

    this.coApplicants.push(
      new FormGroup<CoApplicantForm>({
        firstName: new FormControl<string>('', {
          nonNullable: true,
          validators: [Validators.required, Validators.minLength(2)],
        }),
        lastName: new FormControl<string>('', {
          nonNullable: true,
          validators: [Validators.required],
        }),
        annualIncome: new FormControl<number | null>(null, [
          Validators.required,
          Validators.min(0),
        ]),
        relationship: new FormControl<string | null>(null, [Validators.required]),
      })
    );
  }

  removeCoApplicant(index: number): void {
    this.coApplicants.removeAt(index);
  }

  get coApplicantGroups(): FormGroup<CoApplicantForm>[] {
    return this.coApplicants.controls;
  }
}

Rastreamento de Estado Dirty e Salvar como Rascunho

Formulários longos precisam de funcionalidade de salvar como rascunho. Usuários não devem perder progresso se fecharem a aba do navegador. Meu padrão é fazer subscribe no stream de valueChanges do formulário e auto-salvar em um endpoint de rascunho no back-end, com throttle via auditTime para gravar o último estado em intervalos constantes, combinado com um guard de Unsaved Changes que previne navegação se houver um delta não salvo significativo.

O insight chave aqui é que form.dirty não é um indicador confiável de mudança significativa. O Angular marca um formulário como dirty após a primeira interação do usuário, mesmo se o usuário imediatamente reverter o valor. Para formulários financeiros, rastreio o último valor salvo explicitamente e comparo com o valor atual do formulário usando um deep equality check antes de mostrar o aviso de mudanças não salvas. Isso previne falsos positivos que frustrariam usuários que acidentalmente tocaram um campo e depois corrigiram.

Submissão e Tratamento de Erros

Submissão em formulários financeiros não é fire-and-forget. O usuário precisa saber exatamente o que aconteceu, e o formulário precisa lidar com falhas parciais graciosamente. Meu padrão de submissão sempre envolve: marcar todos os controles como touched para revelar quaisquer erros de validação escondidos, realizar uma passagem final de validação síncrona, submeter com um estado de loading, tratar erros field-level da resposta do servidor mapeando-os de volta para instâncias específicas de FormControl, e fornecer um caminho claro de recuperação para falhas de serviço.

typescript
async submitApplication(): Promise<void> {
  this.markAllControlsTouched(this.form);

  if (this.form.invalid || this.form.pending) {
    this.scrollToFirstError();
    return;
  }

  this.isSubmitting = true;

  try {
    // .value on a typed FormGroup is a Partial of the form's shape, so it will not satisfy a
    // required DTO on its own. The submit boundary is where you assert the shape you just
    // validated for. Disabled sections stay stripped out, which is the point of using .value here.
    const payload = this.form.value as LoanApplicationPayload;

    const result = await firstValueFrom(
      this.loanService.submitApplication(payload)
    );
    this.router.navigate(['/application', result.applicationId, 'confirmation']);
  } catch (error: unknown) {
    if (isFieldValidationError(error)) {
      this.applyServerErrors(error.fieldErrors);
    } else {
      this.submissionError = 'Service unavailable. Your draft has been saved.';
    }
  } finally {
    this.isSubmitting = false;
  }
}

private applyServerErrors(fieldErrors: Record<string, string>): void {
  Object.entries(fieldErrors).forEach(([path, message]) => {
    const control = this.form.get(path);
    control?.setErrors({ serverError: message });
    control?.markAsTouched();
  });
  this.scrollToFirstError();
}

Lições Que Realmente Vieram de Produção

  • Nunca confie apenas no estado valid do formulário para submissão. Chame markAllAsTouched() primeiro para os erros escondidos aparecerem, e cheque o status PENDING para nunca submeter enquanto um validator assíncrono ainda está rodando.
  • Controles desabilitados são excluídos de form.value mas não de form.getRawValue(). Escolha um consistentemente e documente.
  • Validators cross-field em FormGroups grandes rodam em toda emissão de valueChanges de qualquer controle filho. Para validators custosos, mude o grupo para updateOn: 'blur' ou mova a checagem para um validator assíncrono com debounce.
  • Mensagens de erro de formulário devem ser acessíveis. Use aria-describedby para associar mensagens de erro com seus campos, não apenas posicionamento visual.
  • Teste seus validators assíncronos com um service mock que falha intencionalmente -- teste de happy path não é suficiente para validators de formulários financeiros.
  • Timeout de sessão durante um formulário longo é um desastre de UX em bancos. Implemente um heartbeat de sessão e um countdown visível com prompt de Salvar Rascunho antes da sessão expirar.

Formulários financeiros são genuinamente um dos problemas de UI mais desafiadores no desenvolvimento web. Eles combinam alta complexidade de dados, requisitos estritos de validação, mandatos de acessibilidade, restrições regulatórias e alto risco para erros. Angular Reactive Forms, bem usado, está à altura do desafio. Os padrões que descrevi aqui não são acadêmicos. Eles saíram de formulários em produção, não de tutoriais.

Referências

AngularReactive FormsTypeScriptFintech