Функциональное программирование в TypeScript: продвинутый уровень
Функциональное программирование (ФП) — это парадигма, которая в последние годы активно завоевывает мир JavaScript и TypeScript. Для разработчиков, уже знакомых с основами (чистые функции, неизменяемость данных), открывается новый пласт возможностей: работа с монадами, функторами, аппликативами и сложными композициями. В этой статье мы разберем продвинутые концепции ФП на TypeScript с практическими примерами.
1. Иммутабельность и структурный обмен данными
Иммутабельность (неизменяемость) — краеугольный камень ФП. В TypeScript мы можем гарантировать неизменность на уровне типов с помощью readonly и ReadonlyArray. Однако для эффективной работы с большими структурами данных (например, Redux store) используется структурный обмен (structural sharing) — библиотеки вроде Immer или Immutable.js.
Рассмотрим пример с использованием readonly и функции для обновления вложенного объекта без мутации:
type User = {
readonly id: number;
readonly name: string;
readonly address: {
readonly city: string;
readonly zip: string;
};
};
const updateUserCity = (user: User, newCity: string): User => ({
...user,
address: {
...user.address,
city: newCity,
},
});
const user: User = { id: 1, name: 'Alice', address: { city: 'NYC', zip: '10001' } };
const updatedUser = updateUserCity(user, 'LA');
console.log(user.address.city); // NYC (оригинал не изменился)
console.log(updatedUser.address.city); // LA
Обратите внимание: мы создаем новый объект, но повторно используем неизмененные части (например, id и name). Это и есть структурный обмен вручную. Для глубокой вложенности лучше применять библиотеки.
2. Функторы и их реализация
Функтор — это контейнер, который реализует метод map, позволяющий применять функцию к содержимому контейнера, не нарушая его структуры. В TypeScript типичный функтор — это Array (массив). Но мы можем создать свой функтор, например, Maybe, чтобы избежать ошибок с null и undefined.
Реализуем простой функтор Maybe:
class Maybe<T> {
private constructor(private value: T | null) {}
static of<U>(val: U | null | undefined): Maybe<U> {
return new Maybe(val ?? null);
}
map<U>(fn: (val: T) => U): Maybe<U> {
if (this.value === null) {
return Maybe.of<U>(null);
}
return Maybe.of(fn(this.value));
}
getOrElse(defaultValue: T): T {
return this.value !== null ? this.value : defaultValue;
}
}
// Использование
const result = Maybe.of('hello')
.map(s => s.toUpperCase())
.map(s => s + ' world')
.getOrElse('fallback');
console.log(result); // "HELLO WORLD"
const empty = Maybe.of<string>(null)
.map(s => s.toUpperCase())
.getOrElse('default');
console.log(empty); // "default"
Функтор Maybe позволяет безопасно цепочечно вызывать функции, не беспокоясь о null. Каждый map возвращает новый Maybe, сохраняя контекст.
3. Монады: цепочки операций с побочными эффектами
Монада — это функтор, который также реализует метод flatMap (часто называемый bind или chain). Он позволяет "разворачивать" вложенные контейнеры. Классический пример монады — Promise или Array. Рассмотрим монаду Either для обработки ошибок без исключений.
type Either<L, R> = Left<L> | Right<R>;
class Left<L> {
readonly value: L;
constructor(value: L) { this.value = value; }
isLeft(): this is Left<L> { return true; }
isRight(): this is Right<never> { return false; }
map<R2>(_fn: (val: never) => R2): Either<L, R2> { return this as any; }
flatMap<R2>(_fn: (val: never) => Either<L, R2>): Either<L, R2> { return this as any; }
}
class Right<R> {
readonly value: R;
constructor(value: R) { this.value = value; }
isLeft(): this is Left<never> { return false; }
isRight(): this is Right<R> { return true; }
map<R2>(fn: (val: R) => R2): Either<never, R2> { return new Right(fn(this.value)); }
flatMap<L2, R2>(fn: (val: R) => Either<L2, R2>): Either<L2 | never, R2> { return fn(this.value); }
}
// Вспомогательные функции
const left = <L, R = never>(val: L): Either<L, R> => new Left(val);
const right = <R, L = never>(val: R): Either<L, R> => new Right(val);
// Использование для деления с проверкой на ноль
const safeDivide = (a: number, b: number): Either<string, number> => {
if (b === 0) return left('Division by zero');
return right(a / b);
};
const compute = (x: number): Either<string, number> => {
return safeDivide(10, x)
.flatMap(result => safeDivide(result, 2))
.map(r => r * 100);
};
console.log(compute(2)); // Right { value: 250 }
console.log(compute(0)); // Left { value: "Division by zero" }
Монада Either позволяет обрабатывать ошибки как данные, а не через try/catch. Это делает код более предсказуемым и композируемым.
4. Каррирование, композиция и point-free стиль
Каррирование — преобразование функции с несколькими аргументами в последовательность функций с одним аргументом. Композиция — объединение функций, где выход одной становится входом другой. Point-free стиль (бесточечная нотация) позволяет писать код без явного указания аргументов.
Реализуем утилиты для кар