IA · Ingeniería MLOps Análisis de Tipos Avanzado

Analizador de Diseño de Tipos

FlujoPago SaaS — Evaluación de invariantes de dominio en TypeScript
Cliente: FlujoPago SaaS
Stack: TypeScript 5.x / NestJS / Next.js
Tipos analizados: 4
Fecha: 18 jun 2026
Analista: CULTIVA IA · Type Design Analyzer
Resumen de puntuaciones — Tipos revisados
Subscription
2.5
Crítico
Encapsulación
2
Expr. Invariantes
1
Utilidad
3
Cumplimiento
1
Payment
2.0
Crítico
Encapsulación
2
Expr. Invariantes
1
Utilidad
3
Cumplimiento
1
Invoice
2.3
Crítico
Encapsulación
2
Expr. Invariantes
1
Utilidad
3
Cumplimiento
2
Customer
3.0
Mejorable
Encapsulación
3
Expr. Invariantes
2
Utilidad
4
Cumplimiento
3

🔴 Riesgos identificados

CRÍTICO Ambigüedad de unidades monetarias
amount en Subscription, Payment e Invoice no especifica si son euros o céntimos. Un error aquí = cobros 100× incorrectos.
CRÍTICO Estados ilegales representables
Subscription.status = string admite "activo", "Activo", "ACTIVE". Cualquier typo pasa el compilador.
CRÍTICO Refund mayor que amount
Payment.refundAmount: number | null no impide que refundAmount > amount. Riesgo financiero directo.
ALTO gatewayRef null en pago exitoso
Un Payment con status "succeeded" sin gatewayRef hace imposible la reconciliación bancaria.
ALTO Factura sin dirección fiscal
Customer.address puede ser null mientras se emiten facturas — incumplimiento legal en España (IVA).

📋 Prioridades de refactoring

1
Crear tipo Money opaco (euros céntimos)Reemplazar todos los number de dinero por un branded type o clase que impida confusión de unidades.
2
Uniones discriminadas para estadosModelar Subscription y Payment como union types con los campos condicionados al estado, eliminando los string libres.
3
NonEmptyArray<T> en lineItemsUna factura sin líneas es ilegal. Usar un tipo que garantice al menos un elemento.
4
PaymentMethod como union discriminadaSeparar Card / SepaMandate / BankTransfer evita que iban esté presente en pagos con tarjeta.
5
Branded types para emails, IBANs, CIFsUsar string opaco para evitar pasar un email donde se espera un IBAN y viceversa.
Tipo 1 de 4
Subscription
src/domain/subscription.ts
Encaps.
2
Invariantes
1
Utilidad
3
Cumplim.
1
E 1. Encapsulación 2/5
Es una interface pública sin constructor controlado — cualquier código puede crear un objeto Subscription con datos incoherentes.
No hay clase ni módulo que proteja la mutación post-creación; status, endDate y cancelledAt son directamente asignables.
El alias SubscriptionStatus = string no aporta encapsulación alguna; es transparente.
I 2. Expresión de invariantes 1/5
Estado ilegal representable: una Subscription puede tener status="active" y endDate != null simultáneamente.
Unidad monetaria ambigua: amount: number puede ser 29.99 (euros) o 2999 (céntimos). Sin documentación en el tipo.
Currency sin validar: currency: string permite "EUR", "eur", "Euro" o cualquier cadena.
nextBillingDate ignorado: puede ser null aunque status sea "active" — contradice la lógica de negocio.
U 3. Utilidad de invariantes 3/5
Los campos cancelledAt y cancelReason son relevantes para el dominio, pero están desconectados del estado — se rellenan por convención.
La separación de startDate y endDate tiene sentido, pero la ausencia de restricciones permite startDate > endDate.
Los campos en sí representan conceptos reales y alineados con el modelo de cobros recurrentes (positivo).
C 4. Cumplimiento por el sistema de tipos 1/5
Escape hatch total: como todo es primitivo (string, number, Date | null), no hay enforcement en tiempo de compilación.
El compilador no puede detectar transiciones de estado ilegales (ej. active → active, cancelled → active).
El string libre para status hace inútil cualquier switch exhaustivo — se necesitará un default clause siempre.
Valoración global
Subscription es el tipo más crítico del sistema: sus campos son todos primitivos sin restricciones, el estado es un string libre y las relaciones entre campos (status ↔ endDate ↔ nextBillingDate) dependen completamente de convenciones de código no expresadas en el tipo. Cualquier bug en la capa de servicio que asigne un estado incorrecto pasará desapercibido hasta producción.

Antes vs. Después — Refactoring propuesto

✗ Código actual (estados ilegales posibles)
// ❌ string libre — ningún enforcement
export type SubscriptionStatus = string;

export interface Subscription {
  id: string;
  status: SubscriptionStatus;
  amount: number;       // euros? céntimos?
  currency: string;      // "EUR" o "eur" o "Euro"?
  endDate: Date | null;   // null incluso si cancelada
  cancelledAt: Date | null;
  nextBillingDate: Date | null;
}
✓ Versión mejorada (estados ilegales imposibles)
// ✅ Branded type para céntimos
type Cents = number & { readonly _brand: 'Cents' };
type CurrencyCode = 'EUR' | 'USD' | 'GBP';

// ✅ Union discriminada — campos por estado
type Subscription =
  | { status: 'active'; nextBillingDate: Date;
      endDate?: never; cancelledAt?: never }
  | { status: 'paused'; resumeDate: Date | null }
  | { status: 'cancelled'; cancelledAt: Date;
      cancelReason: string; endDate: Date }
  | { status: 'pending'; startDate: Date }
  & {
    id: SubscriptionId; // branded string
    amount: Cents;
    currency: CurrencyCode;
  };
Sugerencias específicas
Union discriminada por statusCada estado lleva exactamente los campos que necesita. nextBillingDate solo existe en "active"; cancelledAt solo en "cancelled".
Branded type CentsReemplazar number por Cents previene mezclar euros y céntimos en asignaciones directas.
Enum o union literal para currency'EUR' | 'USD' | 'GBP' elimina las variantes de capitalización y cadenas inválidas.
SubscriptionId branded stringEvita pasar un customerId donde se espera un subscriptionId — error frecuente en refactors.
Tipo 2 de 4
Payment
src/domain/payment.ts
Encaps.
2
Invariantes
1
Utilidad
3
Cumplim.
1
E1. Encapsulación2/5
Igual que Subscription: interface plana sin constructor controlado. Todos los campos son mutables y asignables directamente.
errorCode y errorMessage existen en todos los estados aunque solo son relevantes en "failed".
I2. Expresión de invariantes1/5
CRÍTICO — refundAmount > amount: El tipo permite refundos mayores al cobro original. Riesgo financiero real y directo.
gatewayRef nullable en succeeded: Un pago "succeeded" sin gatewayRef imposibilita reconciliación con el banco.
refundedAt sin refundAmount: Se puede marcar como reembolsado sin importe, o tener importe sin fecha.
U3. Utilidad de invariantes3/5
Los campos representan conceptos reales (referencia de pasarela, fecha de proceso, error). La utilidad potencial es alta pero no se materializa por falta de restricciones.
La separación entre pago de subscripción y pago puntual mediante subscriptionId | null es semánticamente válida.
C4. Cumplimiento1/5
El compilador no detecta que un pago "succeeded" debe tener gatewayRef y processedAt.
No hay constraint que impida refundAmount > amount — requeriría validación en runtime.
status: string permite cualquier cadena, rompiendo switches exhaustivos.
Valoración global
Payment es el tipo con mayor riesgo financiero directo: la posibilidad de que refundAmount supere amount es un bug potencial de pérdidas económicas reales. La falta de union discriminada implica que cada estado debería validar manualmente qué campos son obligatorios, y esa lógica vive dispersa en la capa de servicio en lugar de estar en el tipo.

Antes vs. Después — Refactoring propuesto

✗ Código actual
export interface Payment {
  status: string;
  amount: number;
  errorCode: string | null;
  errorMessage: string | null;
  processedAt: Date | null;
  refundedAt: Date | null;
  refundAmount: number | null;
  gatewayRef: string | null;
  // ❌ refundAmount puede ser > amount
  // ❌ gatewayRef null en succeeded
}
✓ Versión mejorada
type GatewayRef = string & { readonly _brand: 'GatewayRef' };

type Payment =
  | { status: 'pending'; amount: Cents }
  | { status: 'processing'; amount: Cents;
      gatewayRef: GatewayRef }
  | { status: 'succeeded'; amount: Cents;
      gatewayRef: GatewayRef;  // ✅ requerido
      processedAt: Date;
      refund?: { amount: Cents; at: Date } }
  | { status: 'failed'; amount: Cents;
      errorCode: string;  // ✅ solo en failed
      errorMessage: string };
  // ✅ refund anidado: amount siempre <= parent.amount
  //    validado en constructor de Payment
Sugerencias específicas
Union discriminada por estado de pagosucceeded lleva gatewayRef y processedAt como requeridos. failed lleva errorCode/Message. pending no tiene ninguno de los dos.
Refund como objeto anidado opcionalEn lugar de refundedAt + refundAmount sueltos, un objeto { amount, at } garantiza que si hay reembolso, ambos campos existen.
Branded type GatewayRefDistingue la referencia de pasarela de otros strings, evitando pasarle un invoiceId por accidente.
Validación de refundAmount en constructorSi se usa una clase, el constructor puede lanzar si refund.amount > this.amount, eliminando el riesgo financiero a nivel de tipo.
Tipo 3 de 4
Invoice
src/domain/invoice.ts
Encaps.
2
Invariantes
1
Utilidad
3
Cumplim.
2
E1. Encapsulación2/5
lineItems es un array mutable que puede quedarse vacío post-creación.
total, subtotal y taxAmount son campos independientes — se pueden modificar individualmente rompiendo la aritmética.
I2. Expresión de invariantes1/5
Doble convención en taxRate: el proyecto usa 0.21 y 21 indistintamente. El tipo no lo resuelve.
Array vacío de lineItems: Invoice sin líneas es inválido fiscalmente pero representable en el tipo.
total no es subtotal+tax: ningún mecanismo en el tipo garantiza la aritmética correcta.
quantity puede ser negativo: InvoiceLineItem.quantity: number admite 0 y negativos.
U3. Utilidad3/5
La estructura lineItems + subtotal + taxAmount + total es el modelo correcto para facturación.
La separación de estados (draft/sent/paid/overdue/void) cubre los casos de uso reales, aunque el tipo no los discrimina.
C4. Cumplimiento2/5
El uso de status: string impide switches exhaustivos.
Mejor que Payment/Subscription en que la aritmética podría hacerse readonly en los totales — pero no se hace.
Valoración global
Invoice combina riesgo financiero (totales inconsistentes, taxRate ambiguo) con riesgo legal (factura sin líneas, sin dirección, emitida igualmente). El modelo conceptual es correcto pero necesita NonEmptyArray, tipos opacos para porcentajes y que los totales sean computed readonly en lugar de campos independientes.

Antes vs. Después — Refactoring propuesto

✗ Código actual
type InvoiceLineItem = {
  quantity: number;   // puede ser 0 o negativo
  unitPrice: number;  // euros o céntimos?
  taxRate: number;    // 0.21 o 21?
  total: number;      // ¿calculado o manual?
};

interface Invoice {
  lineItems: InvoiceLineItem[];  // puede estar vacío
  subtotal: number;
  taxAmount: number;
  total: number;               // inconsistente posible
  status: string;
}
✓ Versión mejorada
// ✅ Porcentaje explícito
type TaxRatePercent = 0 | 4 | 10 | 21; // IVA España
type PositiveInt = number & { readonly _brand: 'Pos' };

type LineItem = {
  readonly quantity: PositiveInt;  // ✅ > 0
  readonly unitPrice: Cents;
  readonly taxRate: TaxRatePercent;
  // total eliminado: se computa en Invoice
};

type NonEmptyArray<T> = [T, ...T[]];

type Invoice = { /* campos base */
  lineItems: NonEmptyArray<LineItem>;  // ✅ min 1
  // subtotal/tax/total: computed, no almacenados
} & (/* union discriminada por estado */
  | { status: 'draft' }
  | { status: 'sent'; sentAt: Date; dueDate: Date }
  | { status: 'paid'; paidAt: Date; paymentRef: string }
  | { status: 'void'; voidedAt: Date; voidReason: string }
);
Sugerencias específicas
NonEmptyArray<LineItem>Garantiza que la factura tiene al menos un concepto, haciendo ilegal una factura vacía a nivel de tipo.
TaxRatePercent como union literalLos tipos de IVA en España son 0, 4, 10 y 21. Usar una union literal elimina la ambigüedad 0.21 vs 21 de raíz.
Eliminar total del LineItemtotal = quantity × unitPrice × (1 + taxRate/100) es una función pura. No debe almacenarse; se computa en el método getTotal() de Invoice.
PositiveInt para quantityUn branded type que en su constructor valida que el valor sea > 0 elimina facturas con 0 o -1 unidades.
Tipo 4 de 4
Customer
src/domain/customer.ts
Encaps.
3
Invariantes
2
Utilidad
4
Cumplim.
3
E1. Encapsulación3/5
El objeto address está agrupado en un sub-tipo, mejor que campos planos.
paymentMethod también está agrupado.
Pero ambos son mutable objects, y address | null puede romperse si se accede desde fuera.
I2. Expresión de invariantes2/5
paymentMethod con IBAN en tarjeta: Si type="card", iban debería ser inexistente. Si type="sepa", last4 no aplica. El tipo actual permite cualquier combinación.
Country sin enum: "ES", "Spain", "spain" o "esp" son todas cadenas válidas.
address null en facturación: La lógica de negocio requiere dirección al emitir facturas, pero el tipo no fuerza esta restricción en ese contexto.
U3. Utilidad4/5
El tipo cubre los casos reales de CULTIVA: cliente con/sin VAT, con/sin método de pago.
La opción de vatNumber nullable es correcta para particulares vs. empresas.
La separación de los sub-tipos es conceptualmente adecuada para el dominio.
C4. Cumplimiento3/5
Es el tipo más avanzado del sistema: paymentMethod como objeto agrupado es un buen inicio.
La union discriminada en paymentMethod.type no está implementada — es string libre.
email y vatNumber son strings sin formato validado a nivel de tipo.
Valoración global
Customer es el tipo mejor diseñado del sistema: agrupa sub-objetos, los nullables tienen sentido semántico y la estructura refleja el dominio. Sin embargo, la ausencia de union discriminada en paymentMethod (card vs. sepa vs. transfer) permite combinaciones ilegales de campos, y los branded types para email, vatNumber e IBAN evitarían intercambios accidentales en llamadas a servicios.

Antes vs. Después — Refactoring propuesto

✗ Código actual
interface Customer {
  email: string;         // sin validar
  vatNumber: string | null;
  address: {
    country: string;    // "ES" o "Spain"
  } | null;
  paymentMethod: {
    type: string;      // card|sepa|transfer
    last4: string | null;
    iban: string | null; // null si tarjeta, but..?
    mandateId: string | null;
  } | null;
}
✓ Versión mejorada
type Email = string & { readonly _brand: 'Email' };
type IBAN = string & { readonly _brand: 'IBAN' };
type VatNumber = string & { readonly _brand: 'VatNumber' };
type CountryCode = 'ES' | 'FR' | 'DE' | 'PT' | 'GB';

// ✅ Union discriminada por método de pago
type PaymentMethod =
  | { type: 'card'; last4: string; brand: string }
  | { type: 'sepa'; iban: IBAN; mandateId: string }
  | { type: 'transfer'; iban: IBAN };

interface Customer {
  email: Email;              // ✅ branded
  vatNumber: VatNumber | null;
  address: BillingAddress | null; // country: CountryCode
  paymentMethod: PaymentMethod | null;
}
Sugerencias específicas
Union discriminada PaymentMethodcard solo tiene last4 y brand. sepa tiene iban y mandateId (obligatorios). transfer tiene solo iban. Imposible confundir.
CountryCode como union literalLimitar a los países de operación (ISO 3166-1 alpha-2) elimina variantes "Spain"/"spain" y cadenas inválidas.
Email branded en creaciónUn smart constructor Email.parse(s: string): Email valida el formato con regex en un único punto, propagando la garantía por todo el sistema.
BillingAddress con requeridosCrear un tipo BillingAddress separado con todos los campos obligatorios (street, postalCode, city, country). Invoice puede requerir Customer con BillingAddress (no null).