В архітектурі чистого функціонального програмування та сучасного управління станом React/Redux дотримання суворої імутабельності (Immutability) даних є фундаментальним інваріантом. Зміна будь-якого вузла дерева стану вимагає створення нового посилання для всіх його батьківських контейнерів, щоб алгоритми поверхневого порівняння (Shallow Equality Check, Object.is) у React Fiber та мемоізованих селекторах могли надійно зафіксувати мутацію.
Під час роботи з глибоко вкладеними доменними моделями (дерева сутностей, AST, конфігурації складних форм) класичний синтаксис spread-оператора (...) перетворюється на нечитабельний і схильний до помилок антипатерн «Spread Hell»:
// Крихке, громіздке ручне копіювання 4 рівнів вкладеності:
const nextState = {
...state,
workspace: {
...state.workspace,
project: {
...state.workspace.project,
settings: {
...state.workspace.project.settings,
theme: 'dark',
},
},
},
};Математичним вирішенням цього архітектурного тертя виступає теорія категорій та концепція Оптик (Optics) — компонованих функціональних абстракцій для фокусування, зчитування та модифікації ізольованих підструктур усередині складних незмінних даних. Базовим примітивом оптик є Лінза (Lens), доповнена Призмами (Prisms) для опціональних/варіантних значень та Траверсалами (Traversals) для колекцій.
+-------------------------------------------------------------------------+
| АРХІТЕКТУРА ФОКУСУВАННЯ ЧЕРЕЗ ЛІНЗИ |
+-------------------------------------------------------------------------+
| Вихідна глибока структура: S (RootState) |
+-------------------------------------------------------------------------+
|
| lensUser = prop('user')
v
| Фокус: User |
|
| lensAddress = prop('address')
v
| Фокус: Address |
|
| lensCity = prop('city')
v
+-------------------------------------------------------------------------+
| Композитна лінза: cityLens = compose(lensUser, lensAddress, lensCity) |
| |
| |-- Читання: cityLens.get(state) -> 'Berlin' |
| `-- Запис: cityLens.set('Munich')(state) -> Новий RootState |
| (Усі проміжні посилання оновлені імутабельно!) |
+-------------------------------------------------------------------------+У теорії категорій оптики класифікуються за потужністю цільового фокуса:
Ієрархія функціональних оптик:
+------------+
| Optic |
+------------+
/ \
v v
+------------+ +------------+
| Lens | | Prism |
| (Рівно 1) | | (0 або 1) |
+------------+ +------------+
\ /
v v
+----------------+
| Traversal |
| (Від 0 до N) |
+----------------+Organization -> Department -> Team -> Permissions без порушення посилальної цілісності.Traversal без прямого імперативного перебору масивів у компонентах інтерфейсу.Lens<S, A> на чистому TypeScript#Створимо строго типізований примітив лінзи без зовнішніх залежностей та без використання any:
/**
* Контракт чистої функціональної лінзи.
* S — тип вихідного об'єкта (Source).
* A — тип внутрішнього цільового значення у фокусі (Target).
*/
export interface Lens<S, A> {
readonly get: (source: S) => A;
readonly set: (value: A) => (source: S) => S;
}
/**
* Трансформація цільового значення через функцію-модифікатор.
*/
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);
}
/**
* Створює лінзу, сфокусовану на конкретній властивості об'єкта.
*/
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) => {
// Захист від зайвих алокацій: якщо значення не змінилося, повертаємо вихідний source
if (Object.is(source[key], value)) {
return source;
}
return {
...source,
[key]: value,
};
},
};
}
/**
* Композиція двох лінз: 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);
},
};
}Призма дозволяє безпечно модифікувати поля, які можуть приймати значення undefined або null:
export interface Prism<S, A> {
readonly preview: (source: S) => A | undefined;
readonly set: (value: A) => (source: S) => S;
}
/**
* Створює призму для опціональної властивості об'єкта.
*/
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; // Цільовий вузол відсутній: повертаємо без змін
}
if (Object.is(source[key], value)) {
return source;
}
return {
...source,
[key]: value,
};
},
};
}Продемонструємо лаконічність і типобезпеку модифікації 4 рівнів вкладеності:
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. Оголошуємо атомарні перевикористовувані лінзи
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. Компонуємо шлях фокусу: RootAppState -> city
export const accountCityLens = compose(
compose(accountLens, settingsLens),
compose(addressLens, cityLens)
);
// 3. Тестовий стан застосунку
const initialState: RootAppState = {
version: 1,
account: {
id: 'acc_777',
settings: {
theme: 'dark',
address: {
city: 'Hamburg',
zipCode: '20095',
},
},
},
};
// 4. Імутабельне зчитування:
const currentCity = accountCityLens.get(initialState); // 'Hamburg'
// 5. Імутабельна модифікація одним рядком:
const updatedState = accountCityLens.set('Munich')(initialState);
// 6. Модифікація через функцію over (переведення до верхнього регістру):
const capitalizedState = over(accountCityLens, (c) => c.toUpperCase())(initialState);
// Перевірка посилальної цілісності:
console.log(updatedState.account.settings.address.city); // 'Munich'
console.log(initialState.account.settings.address.city); // 'Hamburg' (вихідний стан не змінено!)
console.log(initialState.version === updatedState.version); // true (незаймані гілки зберегли посилання)Інженер зв'язує ланцюжок дрібних лінз у єдину композитну лінзу, спрямовану на глибоко вкладену властивість.
Лінза спускається деревом об'єкта, повертаючи цільове значення без проміжних алокацій у пам'яті.
Якщо нове значення дорівнює попередньому, лінза негайно повертає вихідний source, блокуючи зайві рендери.
Лінза створює поверхневі копії виключно тих вузлів, які лежать на прямому шляху до зміненої властивості.
import { Lens, prop, compose } from './lens';
/**
* Фабрика лінзи глибини 2 зі строгою перевіркою ключів
*/
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);
}
/**
* Фабрика лінзи глибини 3 зі строгою перевіркою ключів
*/
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);
}Недбало написана лінза створює нову поверхневу копію { ...source, [k]: value }, навіть коли значення збігається зі старим. Це руйнує оптимізацію React.memo та селектори useSyncExternalStore.
// ❌ АНТИПАТЕРН: Порушення посилальної ідентичності при записі незміненого значення
set: (value) => (source) => ({ ...source, [key]: value });
// ✅ ПРАВИЛЬНО: Захисна перевірка Object.is
set: (value) => (source) => {
if (Object.is(source[key], value)) return source;
return { ...source, [key]: value };
};Якщо властивість має тип address?: Address, виклик lensAddress.get(state) поверне undefined, а наступна лінза cityLens.get(undefined) призведе до аварійного падіння з помилкою Cannot read properties of undefined. Для таких властивостей обов'язково використовувати Призми (Prism).
// ❌ АНТИПАТЕРН: Застосування звичайної лінзи до nullable-поля
const badLens = compose(prop('optionalUser'), prop('name')); // Ризик краху рантайму!
// ✅ ПРАВИЛЬНО: Використання Призми з безпечним методом previewПід час спроби змінити елемент масиву неприпустимо використовувати пряме присвоєння array[index] = next. Необхідно повертати нову копію через .map() або спред.
// ❌ АНТИПАТЕРН: Пряма мутація індексу масиву
source.items[index] = nextItem; // Мутація вихідного стану!
// ✅ ПРАВИЛЬНО: Імутабельне заміщення через map
source.items.map((item, i) => (i === index ? nextItem : item));Під час повернення оновленого об'єкта важливо зберігати readonly модифікатори, щоб унеможливити випадкові імперативні мутації в іншому коді.
// ✅ ПРАВИЛЬНО: Суворе збереження Readonly контрактів
export interface Lens<S, A> {
readonly get: (source: S) => A;
readonly set: (value: A) => (source: S) => S;
}Знайдіть ділянки коду з множинними вкладеними спред-операторами (...state.a.b.c) у редюсерах.
Впровадьте інтерфейс Lens<S, A>, хелпери prop(), over() та compose() в архітектурний шар утиліт.
Оголосіть атомарні лінзи для кожного рівня доменної моделі (userLens, settingsLens, addressLens).
Сформуйте складені лінзи для типових операцій оновлення (наприклад, accountCityLens).
Замініть громіздкі спред-операції на лаконічні виклики lens.set(val)(state) зі збереженням посилальної стабільності.