Saltar al contenidoJMRG
Todas las entradas

Patrón Maybe/Option: Adiós null y undefined

design-patternsfunctionalmaybe-optiontypescript

¿Qué es el Patrón Maybe/Option?

Maybe (también conocido como Option) es un contenedor que puede tener un valor (Some/Just) o estar vacío (None/Nothing). Hace explícita la ausencia de valor y fuerza su manejo.

classDiagram
    class Maybe~T~ {
        <<abstract>>
        +map(fn): Maybe~U~
        +flatMap(fn): Maybe~U~
        +getOrElse(default): T
        +match(handlers): R
    }

    class Some~T~ {
        -value: T
        +map(fn): Some~U~
        +getOrElse(default): T
    }

    class None {
        +map(fn): None
        +getOrElse(default): T
    }

    Maybe <|-- Some
    Maybe <|-- None

El Problema con null/undefined

// ❌ Código defensivo por todas partes
function getDiscountedPrice(userId: string): number {
  const user = findUser(userId);
  if (!user) return 0;

  const membership = user.membership;
  if (!membership) return 0;

  const discount = membership.discount;
  if (!discount) return 0;

  return calculateDiscount(discount);
}

// ❌ Runtime errors
function greetUser(user: User | null) {
  console.log(user.name.toUpperCase()); // 💥 Cannot read property 'name' of null
}

Implementación de Maybe

// maybe.ts
abstract class Maybe<T> {
  abstract map<U>(fn: (value: T) => U): Maybe<U>;
  abstract flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U>;
  abstract getOrElse(defaultValue: T): T;
  abstract getOrThrow(error?: Error): T;
  abstract match<R>(handlers: { some: (value: T) => R; none: () => R }): R;

  abstract isSome(): this is Some<T>;
  abstract isNone(): this is None<T>;

  // Factory methods
  static some<T>(value: T): Maybe<T> {
    return new Some(value);
  }

  static none<T>(): Maybe<T> {
    return new None();
  }

  static fromNullable<T>(value: T | null | undefined): Maybe<T> {
    return value != null ? Maybe.some(value) : Maybe.none();
  }
}

class Some<T> extends Maybe<T> {
  constructor(private readonly value: T) {
    super();
  }

  map<U>(fn: (value: T) => U): Maybe<U> {
    return Maybe.some(fn(this.value));
  }

  flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    return fn(this.value);
  }

  getOrElse(_defaultValue: T): T {
    return this.value;
  }

  getOrThrow(_error?: Error): T {
    return this.value;
  }

  match<R>(handlers: { some: (value: T) => R; none: () => R }): R {
    return handlers.some(this.value);
  }

  isSome(): this is Some<T> { return true; }
  isNone(): this is None<T> { return false; }
}

class None<T> extends Maybe<T> {
  map<U>(_fn: (value: T) => U): Maybe<U> {
    return Maybe.none();
  }

  flatMap<U>(_fn: (value: T) => Maybe<U>): Maybe<U> {
    return Maybe.none();
  }

  getOrElse(defaultValue: T): T {
    return defaultValue;
  }

  getOrThrow(error?: Error): T {
    throw error ?? new Error('Called getOrThrow on None');
  }

  match<R>(handlers: { some: (value: T) => R; none: () => R }): R {
    return handlers.none();
  }

  isSome(): this is Some<T> { return false; }
  isNone(): this is None<T> { return true; }
}

Uso Práctico

Búsqueda en Base de Datos

// Repository que retorna Maybe
class UserRepository {
  async findById(id: string): Promise<Maybe<User>> {
    const user = await prisma.user.findUnique({ where: { id } });
    return Maybe.fromNullable(user);
  }

  async findByEmail(email: string): Promise<Maybe<User>> {
    const user = await prisma.user.findUnique({ where: { email } });
    return Maybe.fromNullable(user);
  }
}

// Uso limpio
const userResult = await userRepo.findById(userId);

const greeting = userResult.match({
  some: user => `Hola, ${user.name}!`,
  none: () => 'Usuario no encontrado'
});

Chaining con flatMap

// ✅ Composición elegante
async function getDiscountedPrice(userId: string): Promise<Maybe<number>> {
  return (await userRepo.findById(userId))
    .flatMap(user => Maybe.fromNullable(user.membership))
    .flatMap(membership => Maybe.fromNullable(membership.discount))
    .map(discount => calculateDiscount(discount));
}

// Uso
const price = await getDiscountedPrice(userId);
const finalPrice = price.getOrElse(0);

Acceso a Propiedades Anidadas

// ❌ Sin Maybe - null checks anidados
const street = user?.address?.street?.name ?? 'Unknown';

// ✅ Con Maybe - expresivo y type-safe
const street = Maybe.fromNullable(user)
  .flatMap(u => Maybe.fromNullable(u.address))
  .flatMap(a => Maybe.fromNullable(a.street))
  .map(s => s.name)
  .getOrElse('Unknown');

Combinando Múltiples Maybes

// Función helper para combinar
function combine<A, B, R>(
  maybeA: Maybe<A>,
  maybeB: Maybe<B>,
  fn: (a: A, b: B) => R
): Maybe<R> {
  return maybeA.flatMap(a =>
    maybeB.map(b => fn(a, b))
  );
}

// Uso
const maybeFullName = combine(
  Maybe.fromNullable(firstName),
  Maybe.fromNullable(lastName),
  (first, last) => `${first} ${last}`
);

Maybe vs Optional Chaining

// Optional chaining (?.) es útil pero limitado
const name = user?.profile?.name ?? 'Guest';

// Maybe ofrece más control
const name = Maybe.fromNullable(user)
  .flatMap(u => Maybe.fromNullable(u.profile))
  .map(p => p.name)
  .match({
    some: name => name,
    none: () => {
      logger.warn('Profile not found');
      return 'Guest';
    }
  });

Comparación

Aspecto null/undefined Maybe/Option
Explícito No
Type-safe Parcial Total
Composición Difícil Fácil
Runtime errors Posibles Eliminados
Verbose null checks Métodos chainables

Cuándo Usar

Situación Recomendación
Búsquedas en BD ✅ Ideal
Configuración opcional ✅ Ideal
APIs públicas ✅ Muy útil
CRUD simple interno ⚠️ Puede ser overkill

Conclusión

El patrón Maybe/Option transforma valores opcionales en ciudadanos de primera clase, haciendo el código más seguro, expresivo y fácil de razonar.