fp-ts для TypeScript: полное руководство по функциональному программированию
1. Что это и зачем нужна
fp-ts (Functional Programming in TypeScript) — это мощная библиотека с открытым исходным кодом, которая привносит в TypeScript парадигму функционального программирования (ФП). Она предоставляет набор типов, функций и комбинаторов, вдохновлённых такими языками, как Haskell, PureScript и Scala. В отличие от императивного или объектно-ориентированного подхода, fp-ts позволяет писать код, который является чистым, предсказуемым и легко тестируемым. Библиотека построена на концепциях функторов, монад, аппликативных функторов и других абстракций, которые помогают управлять побочными эффектами, обрабатывать ошибки и работать с асинхронностью без исключений и мутаций состояния.
Зачем использовать fp-ts? В современной разработке на TypeScript часто возникают проблемы: неожиданные исключения (например, при работе с null или undefined), сложность тестирования из-за скрытых зависимостей и побочных эффектов, а также запутанный асинхронный код с колбэками. fp-ts решает эти проблемы, предлагая строгую систему типов и композируемые абстракции. Например, тип Option явно моделирует отсутствие значения, а Either — обработку ошибок без try/catch. Это делает код более безопасным и документированным на уровне типов.
Библиотека особенно полезна в проектах с высокой сложностью бизнес-логики, где важна надёжность и поддерживаемость. Она также отлично подходит для команд, которые хотят внедрить функциональные принципы постепенно, не переписывая весь проект. fp-ts активно поддерживается сообществом и имеет богатую экосистему, включая интеграцию с io-ts (валидация данных), monocle-ts (линзы) и hyper-ts (веб-серверы).
2. Установка
fp-ts распространяется через npm. Для установки выполните команду:
npm install fp-ts
Также рекомендуется установить @types/node для поддержки типов Node.js, если вы работаете не в браузере:
npm install -D @types/node
Для работы с fp-ts требуется TypeScript версии 4.0 или выше. Убедитесь, что в вашем tsconfig.json включена опция "strict": true для максимальной безопасности типов.
3. Быстрый старт — минимальный рабочий пример
Рассмотрим простейший пример: безопасное деление двух чисел с обработкой деления на ноль. Без fp-ts мы бы написали:
function divide(a: number, b: number): number | null {
if (b === 0) return null;
return a / b;
}
const result = divide(10, 2);
if (result !== null) {
console.log(result); // 5
}
С fp-ts используем тип Option:
import { pipe } from 'fp-ts/function';
import { Option, some, none, fromPredicate } from 'fp-ts/Option';
const safeDivide = (a: number, b: number): Option =>
pipe(
b,
fromPredicate((x) => x !== 0),
Option.map((divisor) => a / divisor)
);
const result = safeDivide(10, 2);
// result: Option — some(5) или none
// Извлекаем значение
import { fold } from 'fp-ts/Option';
const message = pipe(
result,
fold(
() => 'Ошибка: деление на ноль',
(value) => `Результат: ${value}`
)
);
console.log(message); // "Результат: 5"
Этот пример демонстрирует ключевые принципы fp-ts: композиция через pipe, явное моделирование отсутствия значения и обработка ошибок без исключений.
4. Основные методы/функции/классы
4.1. Option — работа с опциональными значениями
Сигнатура:
// Конструкторы
declare const some: (a: A) => Option;
declare const none: Option;
// Основные методы
declare const map: (f: (a: A) => B) => (fa: Option) => Option;
declare const chain: (f: (a: A) => Option) => (fa: Option) => Option;
declare const fold: (onNone: () => B, onSome: (a: A) => B) => (fa: Option) => B;
Что делает: Тип Option представляет значение, которое может отсутствовать. some создаёт присутствующее значение, none — отсутствующее. map применяет функцию к внутреннему значению, если оно есть; chain позволяет последовательно вызывать функции, возвращающие Option; fold — это паттерн-матчинг для обработки обоих случаев.
Пример:
import { pipe } from 'fp-ts/function';
import { Option, some, none, map, chain, fold } from 'fp-ts/Option';
const getUserName = (id: number): Option => {
const users: Record = { 1: 'Alice', 2: 'Bob' };
return id in users ? some(users[id]) : none;
};
const getGreeting = (name: string): Option =>
name.length > 0 ? some(`Hello, ${name}!`) : none;
const result = pipe(
getUserName(1),
chain(getGreeting),
fold(
() => 'User not found or invalid name',
(greeting) => greeting
)
);
console.log(result); // "Hello, Alice!"
4.2. Either — обработка ошибок без исключений
Сигнатура:
declare const right: (a: A) => Either;
declare const left: (e: E) => Either;
declare const map: (f: (a: A) => B) => (fa: Either) => Either;
declare const chain: (f: (a: A) => Either) => (fa: Either) => Either;
declare const fold: (onLeft: (e: E) => B, onRight: (a: A) => B) => (fa: Either) => B;
Что делает: Either — это тип для представления одного из двух возможных значений: left (обычно ошибка) или right (успех). В отличие от исключений, ошибки становятся частью сигнатуры функции, что делает код более предсказуемым.
Пример:
import { pipe } from 'fp-ts/function';
import { Either, right, left, map, chain, fold } from 'fp-ts/Either';
type ValidationError = { message: string };
const validateAge = (age: number): Either =>
age >= 0 && age <= 120 ? right(age) : left({ message: 'Invalid age' });
const validateName = (name: string): Either =>
name.length > 0 ? right(name) : left({ message: 'Name cannot be empty' });
const createUser = (name: string, age: number): Either =>
pipe(
validateName(name),
chain((validName) =>
pipe(
validateAge(age),
map((validAge) => `User: ${validName}, Age: ${validAge}`)
)
)
);
const result = createUser('Alice', 25);
console.log(
pipe(
result,
fold(
(error) => `Error: ${error.message}`,
(success) => success
)
)
); // "User: Alice, Age: 25"
4.3. Task — ленивые асинхронные вычисления
Сигнатура:
declare const of: (a: A) => Task;
declare const map: (f: (a: A) => B) => (fa: Task) => Task;
declare const chain: (f: (a: A) => Task) => (fa: Task) => Task;
declare const delay: (milliseconds: number) => (fa: Task) => Task;
Что делает: Task представляет асинхронное вычисление, которое никогда не завершается с ошибкой. Это аналог Promise, но ленивый: вычисление начинается только при вызове (). Идеально для композиции асинхронных операций.
Пример:
import { pipe } from 'fp-ts/function';
import { Task, of, map, chain, delay } from 'fp-ts/Task';
const fetchUser = (id: number): Task =>
of(`User ${id}`); // имитация асинхронного запроса
const fetchPosts = (user: string): Task =>
of([`Post 1 by ${user}`, `Post 2 by ${user}`]);
const program = pipe(
fetchUser(1),
delay(1000), // симуляция задержки
chain(fetchPosts),
map((posts) => posts.join(', '))
);
// Запуск вычисления
program().then(console.log); // Через 1 секунду: "Post 1 by User 1, Post 2 by User 1"
4.4. TaskEither — асинхронные вычисления с обработкой ошибок
Сигнатура:
declare const right: (a: A) => TaskEither;
declare const left: (e: E) => TaskEither;
declare const map: (f: (a: A) => B) => (fa: TaskEither) => TaskEither;
declare const chain: (f: (a: A) => TaskEither) => (fa: TaskEither) => TaskEither;
declare const fold: (onLeft: (e: E) => Task, onRight: (a: A) => Task) => (fa: TaskEither) => Task;
Что делает: TaskEither объединяет возможности Task (ленивая асинхронность) и Either (обработка ошибок). Это основной тип для работы с асинхронными операциями, которые могут завершиться ошибкой.
Пример:
import { pipe } from 'fp-ts/function';
import { TaskEither, right, left, map, chain, fold } from 'fp-ts/TaskEither';
import { Task, of } from 'fp-ts/Task';
type ApiError = { status: number; message: string };
const fetchFromApi = (url: string): TaskEither => {
if (url === '/valid') return right('Data from API');
return left({ status: 404, message: 'Not Found' });
};
const processData = (data: string): TaskEither =>
right(data.length);
const program = pipe(
fetchFromApi('/valid'),
chain(processData),
fold(
(error) => of(`Error ${error.status}: ${error.message}`),
(length) => of(`Data length: ${length}`)
)
);
program().then(console.log); // "Data length: 12"
4.5. Reader — внедрение зависимостей
Сигнатура:
declare const ask: () => Reader;
declare const asks: (f: (r: R) => A) => Reader;
declare const map: (f: (a: A) => B) => (fa: Reader) => Reader;
declare const chain: (f: (a: A) => Reader) => (fa: Reader) => Reader;
Что делает: Reader — это вычисление, которое зависит от некоторого окружения (контекста). Позволяет реализовать внедрение зависимостей (dependency injection) без классов и мутаций. Вычисление выполняется только когда предоставлено окружение.
Пример:
import { pipe } from 'fp-ts/function';
import { Reader, ask, map, chain } from 'fp-ts/Reader';
interface Config {
apiUrl: string;
timeout: number;
}
const getApiUrl: Reader = pipe(
ask(),
map((config) => config.apiUrl)
);
const fetchData = (url: string): Reader =>
pipe(
ask(),
map((config) => `Fetching from ${url} with timeout ${config.timeout}ms`)
);
const program = pipe(
getApiUrl,
chain(fetchData)
);
const config: Config = { apiUrl: 'https://api.example.com', timeout: 5000 };
console.log(program(config)); // "Fetching from https://api.example.com with timeout 5000ms"
4.6. State — управление состоянием
Сигнатура:
declare const get: () => State;
declare const put: (s: S) => State;
declare const modify: (f: (s: S) => S) => State;
declare const map: (f: (a: A) => B) => (fa: State) => State;
declare const chain: (f: (a: A) => State) => (fa: State) => State;
Что делает: State представляет вычисление, которое читает и изменяет некоторое состояние. Возвращает пару (результат, новое состояние). Позволяет моделировать мутации в чистом функциональном стиле.
Пример:
import { pipe } from 'fp-ts/function';
import {