En la arquitectura de programación funcional pura y la gestión moderna del estado en React/Redux, preservar una estricta inmutabilidad de los datos es un invariante ineludible. Mutar cualquier nodo hoja dentro de un árbol de estado exige generar una nueva referencia en todos sus contenedores antecesores para que las comprobaciones de igualdad superficial (Object.is) en React Fiber y selectores memorizados detecten la mutación de forma determinista.
Al manipular estructuras de dominio profundamente anidadas (árboles de entidades, ASTs, configuraciones complejas de formularios), la sintaxis convencional del operador spread (...) degenera en un antipatrón verboso y vulnerable conocido como «Spread Hell»:
// Copia manual frágil y farragosa a lo largo de 4 niveles de anidamiento:
const nextState = {
...state,
workspace: {
...state.workspace,
project: {
...state.workspace.project,
settings: {
...state.workspace.project.settings,
theme: 'dark',
},
},
},
};La solución matemática a esta fricción arquitectónica reside en la teoría de categorías y el concepto de Ópticas (Optics): abstracciones funcionales componibles diseñadas para enfocar, inspeccionar y actualizar subestructuras aisladas dentro de árboles inmutables. La primitiva esencial de las ópticas es la Lente (Lens), complementada por Prismas (Prisms) para valores opcionales o variantes y Traversals para colecciones.
+-------------------------------------------------------------------------+
| ARQUITECTURA DE ENFOQUE MEDIANTE LENTES |
+-------------------------------------------------------------------------+
| Estructura raíz original: S (RootState) |
+-------------------------------------------------------------------------+
|
| lensUser = prop('user')
v
| Foco: User |
|
| lensAddress = prop('address')
v
| Foco: Address |
|
| lensCity = prop('city')
v
+-------------------------------------------------------------------------+
| Lente compuesta: cityLens = compose(lensUser, lensAddress, lensCity) |
| |
| |-- Lectura: cityLens.get(state) -> 'Berlin' |
| `-- Escritura: cityLens.set('Munich')(state) -> Nuevo RootState |
| (¡Todas las referencias intermedias se actualizan |
| de forma estrictamente inmutable!) |
+-------------------------------------------------------------------------+En la teoría de ópticas, las primitivas se clasifican según la cardinalidad de su foco:
Jerarquía de ópticas funcionales:
+------------+
| Optic |
+------------+
/ \
v v
+------------+ +------------+
| Lens | | Prism |
| (Exacto 1) | | (0 ó 1) |
+------------+ +------------+
\ /
v v
+----------------+
| Traversal |
| (0 a N) |
+----------------+Organization -> Department -> Team -> Permissions sin alterar la identidad referencial de ramas no afectadas.Traversal sin iteración imperativa en componentes de interfaz.Lens<S, A> en TypeScript puro#Construcción de una primitiva de lente sin dependencias externas y con tipado estricto sin presencia de any:
/**
* Contrato formal para una lente funcional pura.
* S — Tipo del objeto origen (Source).
* A — Tipo del valor destino enfocado (Target).
*/
export interface Lens<S, A> {
readonly get: (source: S) => A;
readonly set: (value: A) => (source: S) => S;
}
/**
* Modifica el valor enfocado aplicando una función transformadora.
*/
export function over<S, A>(lens: Lens<S, A>, fn: (val: A) => A): (source: S) => S {
return (source: S) => lens.set(fn(lens.get(source)))(source);
}
/**
* Crea una lente enfocada en una propiedad específica de un objeto.
*/
export function prop<S, K extends keyof S>(key: K): Lens<S, S[K]> {
return {
get: (source: S) => source[key],
set: (value: S[K]) => (source: S) => {
// Prevención de alocaciones innecesarias: preserva la referencia si no hay cambio
if (Object.is(source[key], value)) {
return source;
}
return {
...source,
[key]: value,
};
},
};
}
/**
* Composición de dos lentes: Lens<A, B> + Lens<B, C> = Lens<A, C>
*/
export function compose<A, B, C>(lensAB: Lens<A, B>, lensBC: Lens<B, C>): Lens<A, C> {
return {
get: (source: A) => lensBC.get(lensAB.get(source)),
set: (value: C) => (source: A) => {
const currentB = lensAB.get(source);
const nextB = lensBC.set(value)(currentB);
return lensAB.set(nextB)(source);
},
};
}Los prismas posibilitan la navegación segura sobre campos que pueden resolverse a undefined o null:
export interface Prism<S, A> {
readonly preview: (source: S) => A | undefined;
readonly set: (value: A) => (source: S) => S;
}
/**
* Instancia un prisma enfocado en una propiedad opcional de un objeto.
*/
export function optionalProp<S, K extends keyof S>(
key: K
): Prism<S, NonNullable<S[K]>> {
return {
preview: (source: S) => {
const val = source[key];
return (val === null || val === undefined ? undefined : val) as NonNullable<S[K]>;
},
set: (value: NonNullable<S[K]>) => (source: S) => {
if (source[key] === null || source[key] === undefined) {
return source; // Nodo ausente: retorna el objeto sin modificar
}
if (Object.is(source[key], value)) {
return source;
}
return {
...source,
[key]: value,
};
},
};
}Demostración de concisión y seguridad de tipos en estructuras con 4 niveles de anidamiento:
import { prop, compose, over } from '../optics/lens';
export interface Address {
readonly city: string;
readonly zipCode: string;
}
export interface UserSettings {
readonly theme: 'light' | 'dark';
readonly address: Address;
}
export interface UserAccount {
readonly id: string;
readonly settings: UserSettings;
}
export interface RootAppState {
readonly account: UserAccount;
readonly version: number;
}
// 1. Declaramos lentes atómicas reutilizables
export const accountLens = prop<RootAppState, 'account'>('account');
export const settingsLens = prop<UserAccount, 'settings'>('settings');
export const addressLens = prop<UserSettings, 'address'>('address');
export const cityLens = prop<Address, 'city'>('city');
// 2. Componemos la ruta de foco: RootAppState -> city
export const accountCityLens = compose(
compose(accountLens, settingsLens),
compose(addressLens, cityLens)
);
// 3. Estado inicial simulado
const initialState: RootAppState = {
version: 1,
account: {
id: 'acc_777',
settings: {
theme: 'dark',
address: {
city: 'Hamburg',
zipCode: '20095',
},
},
},
};
// 4. Lectura inmutable:
const currentCity = accountCityLens.get(initialState); // 'Hamburg'
// 5. Modificación inmutable en una sola línea:
const updatedState = accountCityLens.set('Munich')(initialState);
// 6. Transformación funcional con over (convertir a mayúsculas):
const capitalizedState = over(accountCityLens, (c) => c.toUpperCase())(initialState);
// Verificación de integridad referencial:
console.log(updatedState.account.settings.address.city); // 'Munich'
console.log(initialState.account.settings.address.city); // 'Hamburg' (¡estado origen intacto!)
console.log(initialState.version === updatedState.version); // true (las ramas intactas conservan referencias)El desarrollador concatena lentes atómicas formando una tubería única hacia la propiedad anidada.
La lente desciende por la estructura devolviendo el valor enfocado sin generar alocaciones intermedias.
Si el valor entrante es idéntico al actual, la lente devuelve inmediatamente la referencia original de source.
La lente clona superficialmente solo los nodos que forman parte de la ruta hacia la clave modificada.
import { Lens, prop, compose } from './lens';
/**
* Fábrica de lentes de 2 niveles con validación estricta de claves
*/
export function path2<
TSource,
K1 extends keyof TSource,
K2 extends keyof TSource[K1]
>(k1: K1, k2: K2): Lens<TSource, TSource[K1][K2]> {
const l1 = prop<TSource, K1>(k1);
const l2 = prop<TSource[K1], K2>(k2);
return compose(l1, l2);
}
/**
* Fábrica de lentes de 3 niveles con validación estricta de claves
*/
export function path3<
TSource,
K1 extends keyof TSource,
K2 extends keyof TSource[K1],
K3 extends keyof TSource[K1][K2]
>(k1: K1, k2: K2, k3: K3): Lens<TSource, TSource[K1][K2][K3]> {
const l12 = path2<TSource, K1, K2>(k1, k2);
const l3 = prop<TSource[K1][K2], K3>(k3);
return compose(l12, l3);
}Una lente defectuosa crea una copia superficial { ...source, [k]: value } incluso si value es idéntico a la propiedad existente. Esto rompe la memorización en React.memo y en los selectores de useSyncExternalStore.
// ❌ ANTIPATRÓN: Ruptura de identidad referencial sin cambio de valor
set: (value) => (source) => ({ ...source, [key]: value });
// ✅ CORRECTO: Comprobación de seguridad mediante Object.is
set: (value) => (source) => {
if (Object.is(source[key], value)) return source;
return { ...source, [key]: value };
};Si un campo posee el tipo address?: Address, invocar lensAddress.get(state) devolverá undefined, provocando que la lente subsiguiente cityLens.get(undefined) lance un error fatal de ejecución Cannot read properties of undefined. Para tales campos es obligatorio recurrir a Prismas (Prism).
// ❌ ANTIPATRÓN: Uso de lente estándar en campos opcionales
const badLens = compose(prop('optionalUser'), prop('name')); // ¡Peligro de fallo en runtime!
// ✅ CORRECTO: Empleo de Prisma con método seguro previewAl modificar un elemento en un array no debe asignarse array[index] = next. Es imprescindible generar una nueva copia inmutable mediante .map() o sintaxis spread.
// ❌ ANTIPATRÓN: Mutación directa sobre índice de array
source.items[index] = nextItem; // ¡Muta el estado anterior!
// ✅ CORRECTO: Sustitución inmutable mediante map
source.items.map((item, i) => (i === index ? nextItem : item));Al retornar el nuevo objeto de estado es prioritario preservar los modificadores readonly para evitar que mutaciones imperativas accidentales escapen del control del compilador.
// ✅ CORRECTO: Mantenimiento estricto de contratos Readonly
export interface Lens<S, A> {
readonly get: (source: S) => A;
readonly set: (value: A) => (source: S) => S;
}Identifique secciones de código con propagaciones spread multinivel (...state.a.b.c) en reducers.
Implemente los contratos Lens<S, A> junto con las utilidades prop(), over() y compose().
Declare lentes específicas para cada segmento de la jerarquía (userLens, settingsLens, addressLens).
Enlace las lentes atómicas en ópticas compuestas para operaciones comunes (por ejemplo, accountCityLens).
Sustituya bloques de spread engorrosos por llamadas directas lens.set(val)(state) con estabilidad referencial.