Volver al Blog
Angular26 de junio de 202612 min de lectura

Formularios complejos en Angular: Lecciones de apps financieras

DR

Doug Ribeiro

12 min de lectura

Los formularios financieros son una categoría específica de problema de UI para la que la mayoría de los tutoriales de Angular no te preparan. Un tutorial típico te muestra un formulario de contacto con un campo de nombre y un campo de email. Una solicitud de préstamo real tiene secciones condicionales que aparecen según el tipo de empleo, reglas de validación cruzada que referencian múltiples inputs simultáneamente, validadores async que llaman a servicios de back-end para verificar elegibilidad en tiempo real, y arrays de campos dinámicos donde el usuario puede agregar o eliminar co-solicitantes o fuentes de ingreso.

A lo largo de los años, desde el portal de activación de seguros que construí en BNP Paribas Cardif hasta la plataforma digital de solicitud de préstamos en la que trabajé en BMO, he desarrollado un conjunto de patrones para Angular Reactive Forms a los que recurro repetidamente. Este post los documenta de la manera que desearía que alguien los hubiera documentado para mí. Los ejemplos de código son ilustrativos, escritos para este post y no sacados del codebase de ningún empleador.

La base: Reactive Forms tipados

Antes que nada, si estás en Angular 14 o superior y no estás usando reactive forms estrictamente tipados, estás dejando una cantidad significativa de seguridad sobre la mesa. En aplicaciones financieras donde un valor incorrecto puede tener consecuencias monetarias reales, la seguridad de tipos en tus formularios no es opcional, es un 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>>([]),
});

La opción nonNullable en FormControl es particularmente importante para campos que siempre deben tener un valor. Significa que llamar reset() en el control lo devuelve a su valor inicial en lugar de a null, lo que previene toda una clase de errores runtime en workflows financieros donde un campo de monto null podría propagarse a lógica de cálculos.

Formularios multi-paso: Aislamiento de estado vs. formulario unificado

Hay dos enfoques principales para formularios multi-paso en Angular. Puedes mantener un solo FormGroup grande que represente toda la solicitud, o puedes mantener formularios separados por paso y componer el objeto de envío final al terminar. Cada enfoque tiene tradeoffs reales.

Generalmente prefiero el enfoque de FormGroup unificado para aplicaciones financieras por una razón clave: validación cross-step. Las solicitudes de préstamo frecuentemente tienen reglas de validación que cruzan pasos: el monto de préstamo solicitado en el paso dos se valida contra el ingreso ingresado en el paso uno. Si tus formularios están aislados por paso, implementar esto limpiamente es difícil. Si tienes un solo reactive form, el validador cross-step es solo un validador cross-field estándar.

El enfoque unificado requiere disciplina con tu form service. Aíslo la instancia del formulario en un servicio inyectable que persiste entre los componentes de pasos, expongo getters tipados para cada sección, y aseguro que los componentes de pasos no mantengan ningún estado de formulario. El componente solo renderiza y maneja interacción del usuario: el estado del formulario vive completamente en el servicio.

Validación dinámica basada en input del usuario

Aquí es donde los formularios financieros se vuelven genuinamente complejos. Las reglas de validación para un solicitante autoempleado son completamente diferentes de las reglas para un empleado asalariado. Los solicitantes jubilados necesitan campos de documentación de pensión que son invisibles e irrelevantes para otros tipos de empleo. El enfoque ingenuo es mostrar y ocultar campos y también agregar y eliminar validadores manualmente. Esto lleva a bugs sutiles donde los validadores quedan activos en campos ocultos.

Mi patrón preferido es llamar setValidators en el grupo visible, clearValidators en los ocultos, y siempre terminar con updateValueAndValidity, combinado con el mecanismo disable de 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));

La llamada a disable() es críticamente importante. Los controles de formulario deshabilitados se excluyen de form.value, lo que significa que los campos irrelevantes se eliminan automáticamente del payload sin ninguna limpieza manual. Si necesitas leer todos los valores independientemente del estado disabled, por ejemplo para repoblar un borrador, usa getRawValue(), que incluye controles deshabilitados. En lógica de envío, siempre usa form.value para que las secciones deshabilitadas nunca lleguen al servidor.

Validadores cross-field

Un validador cross-field es un validador adjunto a un FormGroup en lugar de a un FormControl individual. Recibe el grupo completo como argumento y puede comparar cualquier valor dentro de él. Para aplicaciones financieras, esto es esencial.

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()] }
);

Mostrar errores de validación cross-field requiere un patrón de template ligeramente diferente. El error vive en el grupo, no en ningún control individual, así que lo accedes con form.get('groupName')?.errors?.['affordability'] en lugar de los errors del control individual.

Ciclo de vida del estado del formulario

Estados limpiosPristine. Sin tocar desde la ca…Dirty. El usuario escribióValid. Todos los validators pas…Submitted. El formulario se env…Estados de errorInvalid. La validación fallóPending. Validator async en eje…Touched. El campo perdió el focoDisabled. Campo bloqueadoVS

Validadores async: Verificaciones de elegibilidad en backend

Algunas reglas de validación no pueden computarse del lado del cliente. ¿Este SIN es válido y no está ya registrado? ¿Esta dirección de propiedad está en una región que el prestamista atiende? ¿El solicitante cumple una regla de elegibilidad que solo el back-end conoce? Estos requieren validadores async que llaman a servicios de back-end.

Los validadores async introducen latencia, y necesitas manejar esa latencia cuidadosamente para evitar una experiencia de usuario frustrante. Mi patrón siempre incluye debouncing, un indicador de estado pending, y manejo de errores para cuando el servicio mismo falla:

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>

El timer(400) antes de la llamada API actúa como debounce. Sin esto, cada pulsación de tecla dispararía una petición al backend. El catchError que retorna of({ sinServiceUnavailable: true }) es importante: una caída del servicio se convierte en un error específico que el template puede explicar. Ten presente que cualquier error que devuelve un validador async marca el control como inválido. Si una caída no debe bloquear al usuario de completar el formulario, la lógica de envío tiene que tratar sinServiceUnavailable como una advertencia y no como un bloqueo, y la verificación definitiva corre del lado del servidor al momento del envío.

Patrones de FormArray para campos dinámicos

Las solicitudes de préstamo a menudo permiten a los usuarios agregar múltiples fuentes de ingreso, múltiples propiedades, o múltiples co-solicitantes. FormArray es el mecanismo de Angular para esto, pero gestionar FormArrays tipados con grupos anidados complejos requiere algunos patrones 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;
  }
}

Tracking de estado dirty y guardar como borrador

Los formularios largos necesitan funcionalidad de guardar como borrador. Los usuarios no deberían perder progreso si cierran la pestaña del navegador. Mi patrón es suscribirme al stream valueChanges del formulario y auto-guardar a un endpoint de borrador en el back-end, regulado con auditTime para que guarde el último estado a intervalos constantes, combinado con un guard de Cambios sin Guardar que previene la navegación si hay un delta sin guardar significativo.

El insight clave aquí es que form.dirty no es un indicador confiable de cambio significativo. Angular marca un formulario como dirty después de la primera interacción del usuario, incluso si el usuario revierte inmediatamente el valor. Para formularios financieros, rastrea el último valor guardado explícitamente y compáralo con el valor actual del formulario usando un deep equality check antes de mostrar la advertencia de cambios sin guardar. Esto previene falsos positivos que frustrarían a usuarios que accidentalmente tocaron un campo y luego lo corrigieron.

Envío y manejo de errores

El envío en formularios financieros no es fire-and-forget. El usuario necesita saber exactamente qué pasó, y el formulario necesita manejar fallos parciales con gracia. Mi patrón de envío siempre involucra: marcar todos los controles como touched para mostrar cualquier error de validación oculto, realizar un pase final de validación síncrona, enviar con un estado de carga, manejar errores a nivel de campo de la respuesta del servidor mapeándolos de vuelta a instancias específicas de FormControl, y proporcionar un camino claro de recuperación para fallos de servicio.

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();
}

Lecciones que realmente vinieron de producción

  • Nunca confíes solo en el estado valid del formulario para envío: llama markAllAsTouched() primero para que afloren los errores ocultos, y revisa el estado PENDING para no enviar nunca mientras un validador async sigue corriendo.
  • Los controles deshabilitados se excluyen de form.value pero no de form.getRawValue(). Elige uno consistentemente y documéntalo.
  • Los validadores cross-field en FormGroups grandes corren en cada emisión de valueChanges de cualquier control hijo. Para validadores costosos, cambia el grupo a updateOn: 'blur' o mueve la verificación a un validador async con debounce.
  • Los mensajes de error de formularios deben ser accesibles. Usa aria-describedby para asociar mensajes de error con sus campos, no solo posicionamiento visual.
  • Testea tus validadores async con un servicio mock que falle intencionalmente: el testing de happy path no es suficiente para validadores de formularios financieros.
  • El timeout de sesión durante un formulario largo es un desastre de UX en banca. Implementa un heartbeat de sesión y un countdown visible con un prompt de Guardar Borrador antes de que la sesión expire.

Los formularios financieros son genuinamente uno de los problemas de UI más desafiantes en desarrollo web. Combinan alta complejidad de datos, requisitos estrictos de validación, mandatos de accesibilidad, restricciones regulatorias, y altas consecuencias por errores. Angular Reactive Forms, bien usados, están a la altura del desafío. Los patrones que he descrito aquí no son académicos: salieron de formularios en producción, no de tutoriales.

Referencias

AngularReactive FormsTypeScriptFintech