En el desarrollo tradicional con JavaScript y TypeScript predomina el modelo imperativo de manejo de excepciones mediante el operador throw y los bloques try/catch. Sin embargo, en la arquitectura enterprise este enfoque introduce un defecto fundamental: firmas de tipos deshonestas (Dishonest Type Signatures).
Analicemos una función clásica:
async function fetchUserProfile(userId: string): Promise<UserProfile> {
// Puede lanzar NetworkError, ParseError, UnauthorizedError o NotFoundError
}La firma de la función afirma que el resultado de ejecución será estrictamente Promise<UserProfile>. La información sobre fallos potenciales queda completamente oculta al sistema de análisis estático de TypeScript. El compilador no obliga al desarrollador a manejar excepciones y, dentro del bloque catch (error: unknown), el tipo de error se degrada a unknown, forzando conversiones de tipo inseguras.
El paradigma funcional resuelve este problema transformando los errores y la ausencia de datos en valores de primera clase (Errors as Values):
Maybe): sustituto determinista de null y undefined.Result): alternativa con seguridad de tipos al mecanismo de excepciones throw.En lugar de quebrar la pila de llamadas mediante throw, los cómputos se organizan como una vía ferroviaria de dos carriles (Railway Oriented Programming):
+-------------------------------------------------------------------------+
| PROCESAMIENTO POR PIPELINE DE CÓMPUTO (EITHER) |
+-------------------------------------------------------------------------+
| Vía exitosa (Right): |
| [JSON entrada] ---> map(parseDTO) ---> flatMap(validate) ---> Right(User)|
| | | |
| v (fallo) v (fallo) |
| Vía de error (Left): | | |
| [Excepción] ---> Left(SyntaxError) Left(ValidationError) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| DESEMPAQUETADO TERMINAL (MATCH / FOLD) |
| match({ |
| onLeft: (error) => renderErrorBanner(error), // Rama de error estricta|
| onRight: (user) => renderUserProfile(user) // Tipo puro User |
| }) |
+-------------------------------------------------------------------------+Cualquier error desvía la ejecución al carril inferior (Left). Las llamadas posteriores a map y flatMap se omiten automáticamente, eliminando las comprobaciones anidadas if (err) return.
Option sin caídas por TypeError: Cannot read property of undefined.Implementemos la arquitectura de referencia de Option y Either basada en uniones discriminadas (Discriminated Unions) con tipado exhaustivo:
export interface Some<A> {
readonly _tag: 'Some';
readonly value: A;
}
export interface None {
readonly _tag: 'None';
}
export type Option<A> = Some<A> | None;
export const Option = {
some: <A>(value: A): Option<A> => ({ _tag: 'Some', value }),
none: (): Option<never> => ({ _tag: 'None' }),
fromNullable: <A>(value: A | null | undefined): Option<NonNullable<A>> =>
value !== null && value !== undefined
? Option.some(value as NonNullable<A>)
: Option.none(),
isSome: <A>(fa: Option<A>): fa is Some<A> => fa._tag === 'Some',
isNone: <A>(fa: Option<A>): fa is None => fa._tag === 'None',
map: <A, B>(fa: Option<A>, f: (a: A) => B): Option<B> =>
fa._tag === 'Some' ? Option.some(f(fa.value)) : Option.none(),
flatMap: <A, B>(fa: Option<A>, f: (a: A) => Option<B>): Option<B> =>
fa._tag === 'Some' ? f(fa.value) : Option.none(),
getOrElse: <A>(fa: Option<A>, onNone: () => A): A =>
fa._tag === 'Some' ? fa.value : onNone(),
match: <A, B>(fa: Option<A>, onNone: () => B, onSome: (a: A) => B): B =>
fa._tag === 'Some' ? onSome(fa.value) : onNone(),
};export interface Left<E> {
readonly _tag: 'Left';
readonly left: E;
}
export interface Right<A> {
readonly _tag: 'Right';
readonly right: A;
}
export type Either<E, A> = Left<E> | Right<A>;
export const Either = {
left: <E, A = never>(error: E): Either<E, A> => ({ _tag: 'Left', left: error }),
right: <A, E = never>(value: A): Either<E, A> => ({ _tag: 'Right', right: value }),
isLeft: <E, A>(ma: Either<E, A>): ma is Left<E> => ma._tag === 'Left',
isRight: <E, A>(ma: Either<E, A>): ma is Right<A> => ma._tag === 'Right',
// Puente seguro desde código imperativo con throw hacia Either
tryCatch: <E, A>(
tryFn: () => A,
onError: (error: unknown) => E
): Either<E, A> => {
try {
return Either.right(tryFn());
} catch (err: unknown) {
return Either.left(onError(err));
}
},
map: <E, A, B>(ma: Either<E, A>, f: (a: A) => B): Either<E, B> =>
ma._tag === 'Right' ? Either.right(f(ma.right)) : Either.left(ma.left),
flatMap: <E, A, B>(ma: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> =>
ma._tag === 'Right' ? f(ma.right) : Either.left(ma.left),
match: <E, A, B>(
ma: Either<E, A>,
onLeft: (error: E) => B,
onRight: (value: A) => B
): B => (ma._tag === 'Left' ? onLeft(ma.left) : onRight(ma.right)),
};Construyamos un pipeline con seguridad de tipos para procesar datos de usuario sin lanzar una sola excepción:
// Definición de unión discriminada de errores de dominio
export type DomainError =
| { readonly code: 'INVALID_JSON'; readonly raw: string }
| { readonly code: 'VALIDATION_FAILED'; readonly field: string; readonly reason: string }
| { readonly code: 'UNAUTHORIZED'; readonly userId: string };
export interface UserAccount {
readonly id: string;
readonly email: string;
readonly balanceCents: number;
}
// 1. Parseo seguro de cadena
export function parseRawJson(rawText: string): Either<DomainError, unknown> {
return Either.tryCatch(
() => JSON.parse(rawText) as unknown,
() => ({ code: 'INVALID_JSON', raw: rawText })
);
}
// 2. Validación de estructura de datos
export function validateAccountDto(input: unknown): Either<DomainError, UserAccount> {
if (typeof input !== 'object' || input === null) {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'root',
reason: 'Payload must be an object',
});
}
const candidate = input as Record<string, unknown>;
if (typeof candidate.id !== 'string' || candidate.id.trim() === '') {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'id',
reason: 'ID must be a non-empty string',
});
}
if (typeof candidate.email !== 'string' || !candidate.email.includes('@')) {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'email',
reason: 'Invalid email address format',
});
}
if (typeof candidate.balanceCents !== 'number' || candidate.balanceCents < 0) {
return Either.left({
code: 'VALIDATION_FAILED',
field: 'balanceCents',
reason: 'Balance must be a non-negative integer',
});
}
return Either.right({
id: candidate.id,
email: candidate.email,
balanceCents: candidate.balanceCents,
});
}
// 3. Composición del pipeline
export function processRawAccount(rawJson: string): Either<DomainError, UserAccount> {
const parsed = parseRawJson(rawJson);
return Either.flatMap(parsed, validateAccountDto);
}Recepción de datos externos crudos (payload JSON, cabeceras HTTP, storage).
Envolver JSON.parse inseguro en Either sin propagar excepciones no controladas.
Si el parseo es exitoso se valida; si hay error, el validador se omite por completo.
Tratamiento forzoso de ambos resultados: generar HTTP 400 o continuar con la lógica de negocio.
Para permitir composición lineal de funciones sin anidación excesiva de llamadas, creamos el combinador tipado pipe:
export function pipe<A>(a: A): A;
export function pipe<A, B>(a: A, ab: (a: A) => B): B;
export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
export function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D;
export function pipe<A, B, C, D, E>(
a: A,
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E
): E;
export function pipe<A, B, C, D, E, F>(
a: A,
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E,
ef: (e: E) => F
): F;
export function pipe(
value: unknown,
...fns: ReadonlyArray<(arg: unknown) => unknown>
): unknown {
return fns.reduce((acc, fn) => fn(acc), value);
}Uso en código de aplicación:
import { pipe } from '@/functional/pipe';
const result = pipe(
'{"id":"usr_42","email":"[email protected]","balanceCents":1500}',
parseRawJson,
(either) => Either.flatMap(either, validateAccountDto),
(either) =>
Either.match(
either,
(err) => `Error de procesamiento: ${err.code}`,
(acc) => `Perfil cargado con éxito: ${acc.email} ($${acc.balanceCents / 100})`
)
);Acceder directamente a fa.value o ma.right sin verificar _tag === 'Some' o _tag === 'Right' destruye las garantías de tipos y produce undefined en runtime.
// ❌ Antipatrón: acceso directo a las propiedades internas de Either
function unsafeUnwrap(result: Either<string, number>): number {
// Compila en TypeScript, pero devuelve undefined en runtime si result es Left!
return (result as { right: number }).right;
}
// ✅ Solución correcta: usar match o getOrElse
function safeUnwrap(result: Either<string, number>): number {
return Either.match(
result,
(err) => 0, // Fallback en caso de error
(val) => val
);
}Invocar throw new Error() dentro de una función pura pasada a Either.map rompe la encapsulación del pipeline, quiebra la pila de llamadas y oculta el fallo al sistema de tipos.
Solución: toda operación susceptible de lanzar excepciones debe envolverse en Either.tryCatch.
Retornar Option<T | null> produce doble incertidumbre. El valor debe normalizarse inmediatamente en el borde de entrada mediante Option.fromNullable(val) y operar en adelante exclusivamente con Option<T>.
Definir una unión discriminada de errores (DomainError) con un tag literal de tipo string code.
Reemplazar llamadas directas a fetch, JSON.parse y fs por funciones que retornen Either<E, A>.
Construir un pipeline lineal pasando el resultado del paso anterior al siguiente con Either.flatMap.
En el borde de la aplicación (controlador HTTP, componente de UI), invocar match para procesar éxitos y fallos.
Suprimir bloques redundantes de captura para mantener un flujo de ejecución limpio y predecible.