Functional Programming Using Typescript
В цій статті я хочу розповісти про функціональне програмування на Тайпскріпт за допомогою бібліотеки fp-ts. Далі ми розглянемо основні принципи функціонального програмування, особливості бібліотеки та декілька прикладів коду з використанням всього пройденого.
Функціональне програмування
Функціональне програмування (далі ФП) передбачає створення чистих функцій для фіксованих змінних. Чисті функції завжди повертають однаковий результат при однакових вхідних даних і не мають ніяких зовнішніх значень, які впливають на нього. Наприклад, наступний код є прикладом чистої функції:
interface Croissant {
name: string;
ingredients: string[];
tasteRating: number;
}
const croissants: Croissant[] = [
{
name: 'Croissant with cheese and mushrooms',
ingredients: ['cheese', 'mushrooms'],
tasteRating: 4.3,
},
{
name: 'Croissant with tuna, lettuce and tomato',
ingredients: ['tuna', 'lettuce', 'tomato'],
tasteRating: 4.7,
},
{
name: 'Croissant with mozzarella',
ingredients: ['mozzarella', 'tomato', 'egg'],
tasteRating: 4.1,
},
{
name: 'Croissant with brie cheese and ham',
ingredients: ['raw smoked ham', 'brie cheese'],
tasteRating: 4.5,
},
];
const getMostDeliciousCroissant = (croissants: Croissant[]): Croissant => {
return croissants.reduce((prev, curr) =>
prev.tasteRating > curr.tasteRating ? prev : curr,
);
};
console.log(getMostDeliciousCroissant(croissants));
// output: {
// name: 'Croissant with tuna, lettuce and tomato',
// ingredients: ['tuna', 'lettuce', 'tomato'],
// tasteRating: 4.7
// }
Також, ФП підвищує розуміння та читаність коду. Використання чистих функцій передбачає їх незмінність стану до самого кінця. Тим самим, цю ж функцію можна використати в різних частинках коду.
Але якщо перший аргумент функції зазвичай є незмінним і ми не хочемо кожного разу передавати в нього одне і те ж значення, то можна використати каррування.
Каррування
Що ж таке каррування? Каррування – це перетворення однієї функції з багатьма аргументами у набір функцій одного аргументу. Думаю, зрозуміліше буде розглянути на прикладі. Функція з аргументами:
const add = (a, b, c) => a + b + c;
Каррування функції:
function add(a) {
return (b) => {
return (c) => {
return a + b + c;
};
};
}
Скорочений варіант виглядає наступним чином:
const add = (a) => (b) => (c) => a + b + c;
Тобто, на кожному етапі ми передаємо аргумент та отримуємо нову функцію. Такий підхід допомагає нам позбавитись від написання одного і того ж коду. Наприклад, у нашого круасана є ціна і нам потрібно порахувати фінальну ціну враховуючи знижку:
При підході з передаванням аргументів наш код виглядав би так:
const totalWithDiscount = (price: number, discount: number) => {
return price * discount;
};
Для кожного круасана ми б передавали ціну та знижку. Але якщо наша знижка фіксована, то ми можемо використати каррування:
const totalWithDiscount = (discount: number) => (price: number) => {
return price * discount;
};
const tenPercentDiscount = totalWithDiscount(0.1);
Далі, для підрахунку ціни кожного круасана нам потрібно лише передати в функцію tenPercentDiscount
price:
tenPercentDiscount(500); // 50
Також, немало важливим підходом в ФП є композиція. Вона створює більш складну функціональність шляхом об’єднання простих функцій.
Композиція
Композиція - це вкладання функцій, кожна з яких передає свій результат як вхідні дані для іншої функції, наприклад:
const getIngredients = (): Ingredient[] => { … };
const makeCroissant = (ingredients: Ingredient[]): Croissant => { … };
const makeOrder = (): Order => { … };
makeOrder(makeCroissant(getIngredients()));
Виконання коду вище ділиться на три етапи:
- Отримуємо інгредієнти викликаючи функцію getIngredients
- Викликаємо функцію makeCroissant та передаємо в неї отримані інгредієнти
- Викликаємо функцію makeOrder та передаємо в неї результат makeCroissant
Імперативний підхід
Давайте розглянемо приклад додавання інгредієнтів до круасана використовуючи інтерфейс з прикладу вище.
function addFish(ingredients: string[]): void {
ingredients.push('fish');
}
function addAvocado(ingredients: string[]): void {
ingredients.push('avocado');
}
function addOmelette(ingredients: string[]): void {
ingredients.push('omelette');
}
const makeCroissant = (): Croissant => {
const croissant: Croissant = {
name: 'Tasty Croissant',
ingredients: [],
tasteRating: 4,
};
let newIngredients: string[] = [];
addAvocado(newIngredients);
addOmelette(newIngredients);
addFish(newIngredients);
croissant.ingredients = newIngredients;
return croissant;
};
console.log(makeCroissant());
// output: {
// name: 'Croissant with tuna, lettuce and tomato',
// ingredients: ['avocado', 'omelette', 'fish'],
// tasteRating: 4.8,
// }
Як можна побачити, для цього підходу характерно використання іменованих змінних та оператора привласнення. Далі ми покращимо цей код використовуючи бібліотеку fp-ts.
Використання бібліотеки fp-ts
В цій статті я вибрав бібліотеку fp-ts, тому що, на мою думку, вона найпопулярніша та найефективніша для функціонального програмування. Але зазвичай потрібно знати й аналоги. Тож, ми маємо кілька аналогів:
- Purify-ts – бібліотека для ФП на TypeScript. В ній менше функціоналу, але краща документація.
- Ramda – бібліотека для ФП на JavaScript.
- Folktale – ще одна бібліотека для ФП на Javascript.
Чому потрібно використовувати fp-ts, якщо можна просто писати чисті функції та гарно типізувати код?
Перша причина – це безпека типів. Бібліотека fp-ts дозволяє писати визначення структур даних без написання визначених користувачем засобів захисту типів або використання оператора as.
Друга причина – це виразність та читабельність. Fp-ts надає необхідні інструменти для елегантного моделювання послідовності операцій, які можуть викликати збій. Також, з її допомогою можна красиво хендлити помилки.
Оператор pipe
Базовий будуючий блок у fp-ts – це оператор pipe. Цей оператор можна використовувати, щоб об’єднати послідовність функцій зліва направо. Визначення типу pipe приймає довільну кількість аргументів. Першим аргументом може бути будь-яке довільне значення, а наступні аргументи мають бути функціями арності один (тобто приймати тільки один аргумент).
Розглянемо оператор pipe на простому прикладі:
Отже, вхідні дані в нас - булочка для круасана. На кожному етапі ми додаємо в круасан якийсь інгредієнт, тобто виконуємо певну функцію. Після проходження функції, круасан має в собі певне наповнення та переходить до іншої функції.
Таким чином, після виконання функції addAvocado()
, дані, які передаються в функцію addOmelette()
, будуть вже з авокадо і так далі.
Оператор pipe можна реалізувати наступним чином:
const pipe = (x, ...fns) => fns.reduce((acc, el) => el(acc), x);
Давайте перепишемо наш приклад додавання інгредієнтів. Звісно, ми могли б використати оператор, зроблений нами власноруч, але в бібліотеці fp-ts він є вже готовий з коробки:
import { append } from 'fp-ts/lib/Array';
import { pipe } from 'fp-ts/lib/function';
interface Croissant {
name: string;
ingredients: string[];
tasteRating: number;
}
const addAvocado = (ingredients: string[]) => append('avocado')(ingredients);
const addFish = (ingredients: string[]) => append('fish')(ingredients);
const addOmelette = (ingredients: string[]) => append('omelette')(ingredients);
const makeCroissant = (): Croissant => ({
name: 'Tasty Croissant',
ingredients: pipe([], addAvocado, addFish, addOmelette),
tasteRating: 4.8,
});
В даному прикладі функції addAvocado
, addFish
, addOmelette
виступають у ролі врапперів та використовують каррування. Ці функції можна також записати у такому вигляді:
const addAvocado = append('avocado');
а логіка залишиться такою ж.
Також, в цьому прикладі ми також використали функцію append
з модуля fp-ts/lib/Array
. Ця функція додає елемент в масив та перетворює його в NonEmptyArray
, а виглядає він так:
export type NonEmptyArray<A> = Array<A> & {
0: A;
};
Тобто це простий типізований масив з хоча б одним елементом в ньому.
Повертаючись до переписаного нами коду можна сказати, що кожного разу, коли нам потрібно зробити якусь маніпуляцію з даними, ми просто можемо додати функцію в pipe. Коротко кажучи, ви можете використовувати оператор pipe для перетворення будь-якого значення за допомогою послідовності функцій. Потік можна змоделювати наступним чином:
A -> (A -> B) -> (B -> C) -> (C -> D)
Аналогом pipe
є оператор flow
. Він працює практично так само, як і оператор pipe
, але відрізняється послідовністю передачі даних. У випадку використання flow
, відмінністю від pipe
є те, що першим параметром повинна бути функція, а дані передаються в кінці. Це все має такий вигляд:
flow(addAvocado, addFish, addOmelette)(croissant);
Тобто за допомогою каррування ми передаємо круасан в оператор flow
та виконуємо маніпуляції з цими даними.
Функції для маніпуляцій з масивами
Типи Array
і ReadonlyArray
від fp-ts розширюють свої аналоги з рідного TypeScript, тому їх можна використовувати без будь-якого перетворення.
Ці модулі забезпечують еквівалентні функції, як Array.prototype.map
або Array.prototype.filter
, але з більш дружнім API від fp-ts.
Приклад readonlyArray
з fp-ts:
import { readonlyArray } from 'fp-ts';
const croissants: Croissant[] = [...];
pipe(
array,
readonlyArray.filter(isTasty),
readonlyArray.map(getCroissantsNames)
);
В той час, як для імперативного підходу нам знадобиться присвоювати фільтрацію та маппінг у конкретні змінні.
Також, однією з корисних функцій є flatten
. За допомогою неї можна перетворити вкладені масиви в один загальний, наприклад:
import * as A from 'fp-ts/lib/Array';
import { pipe } from 'fp-ts/lib/pipeable';
const croissants: Croissant[] = […];
const allIngredients = pipe(
croissants,
A.map(croissant => croissant.ingredients),
A.flatten
); // => [‘fish’, ‘omelette’, ‘avocado’, ‘cheese’]
Сортувати дані можна за допомогою екземплярів Ord
, візьмемо allIngredients з попереднього прикладу та відсортуємо його:
import { array, string } from 'fp-ts';
pipe(allIngredients, array.sort(string.Ord)); // => ['avocado', 'cheese', 'fish', ‘omelette’]
Існують різні екземпляри Ord
для примітивних типів, доступних у fp-ts
:
- Рядки:
string.Ord: Ord<string>
- Числа:
number.Ord: Ord<number>
- Логічні
значення: boolean.Ord: Ord<boolean>
- Дати:
date.Ord: Ord<Date>
Але припустимо у нас є складніша структура, типу Croissant
За допомогою ord.contramap
ми можемо створити різні екземпляри Ord<Croissant>
відповідно до наших потреб. Ця функція лише спосіб визначити, як отримати доступ до поля, яке буде використовуватися для сортування:
import { string, ord, number } from 'fp-ts';
const byName = pipe(
string.Ord,
ord.contramap((croissant: Croissant) => croissant.name),
);
const byTasteRating = pipe(
number.Ord,
ord.contramap((croissant: Croissant) => croissant.tasteRating),
);
І, нарешті, якщо ми хочемо відсортувати за декількома полями, то ми можемо використати array.sortBy
:
pipe(croissants, array.sortBy([byName, byTasteRating]));
Функції для маніпуляції з об’єктами
В бібліотеці fp-ts є модуль для маніпуляції з об’єктами, який називається Record
. Його можна імпортувати за наступним шляхом fp-ts/lib/Record
. Цей модуль має багато функціоналу, але головними його фішками є:
- Маппінг по об’єкту за допомогою map.
import { map } from 'fp-ts/Record';
const pow = (n: number) => n * n;
const newObject = map(pow)({ a: 3, b: 5 });
console.log(newObject); // {a: 9, b: 25}
- Фільтрація полів об’єкту за допомогою filter.
import { filter } from 'fp-ts/Record';
const filtered = filter((s: string) => s.length < 4)({
a: 'foo',
b: 'bar',
c: 'verylong',
});
console.log(filtered); // {a: 'foo', b: 'bar'}
- Видалення конкретного поля за назвою ключа за допомогою deleteAt.
import { deleteAt } from 'fp-ts/Record';
const withoutField = deleteAt('a')({ a: 1, b: 2 });
console.log(withoutField); // { b: 2 }
- Валідація кожного поля об’єкту за допомогою every.
import { every } from 'fp-ts/Record';
const isValidFields = every((n: number) => n >= 0)({ a: 1, b: 2 });
console.log(isValidFields); // true
- Перетворення об’єкту в масив:
import { toArray } from 'fp-ts/Record';
const object = { c: 3, a: 'foo', b: false };
const arrayFromObject = toArray(object);
console.log(arrayFromObject); // [ ['a', 'foo'], ['b', false], ['c', 3] ]
Error Handling
Option
Option – це контейнер, який може бути в одному зі станів – None або Some. None – у випадку, коли значення відсутнє, Some – у випадку коли значення присутнє.
Його визначення виглядає так:
type Option<A> = None | Some<A>;
interface None {
readonly _tag: 'None';
}
interface Some<A> {
readonly _tag: 'Some';
readonly value: A;
}
У нас є тег, який відповідає за стан. None
, коли значення відсутнє та Some
, коли присутнє. Поле value Option використовується у випадку, коли в нас є опціональні дані або коли ми отримуємо дані з бази даних та не можемо бути впевнені у їх наявності. Наприклад ми хочемо отримати замовлення з купленим круасаном:
import * as O from 'fp-ts/lib/Option';
interface Order {
id: string;
croissant: Croissant;
price: number;
}
function getOrder(_id: string): Promise<O.Option<Order>> {
return collection('order').findOne({ _id }).then(O.fromNullable);
}
Функція fromNullable огортає значення отримане з бази даних. У випадку якщо значення null/undefined то getOrder поверне None, в іншому випадку поверне Some(order).
Either
Іншою структурою, дещо схожою на Option, є Either.
interface Left<E> {
readonly _tag: 'Left';
readonly left: E;
}
interface Right<A> {
readonly _tag: 'Right';
readonly right: A;
}
type Either<E, A> = Left<E> | Right<A>;
Тип Either <E, A>
виражає ідею обчислення, яке може розходитися двома способами: лівим, що призводить до значення типу E, або правим, що призводить до значення типу A.
Історично існувала умовність при якій лівий шлях вважається носієм помилкових даних, а правий – успішним результатом.
Подивімось на приклад імперативного коду, який створює винятки та перепишемо його у функціональному стилі. В цьому прикладі ми додамо певні перевірки до коду, який ми писали впродовж статті.
import { pipe } from 'fp-ts/function';
import { either as E, nonEmptyArray as NEA } from 'fp-ts';
import * as A from 'fp-ts/lib/Array';
import { isLeft } from 'fp-ts/lib/Either';
interface Croissant {
name: string;
ingredients: string[];
tasteRating: number;
}
class IngredientsOverflowError extends Error {}
const MAX_INGREDIENTS_LENGTH = 5;
const addAvocado = (
ingredients: string[]
): E.Either<IngredientsOverflowError, NonEmptyArray<string>> => {
if (ingredients.length >= MAX_INGREDIENTS_LENGTH) {
return E.left(
new IngredientsOverflowError(
`There are already ${IngredientsOverflowError} ingredients.`
)
);
}
return E.right(A.append('avocado')(ingredients));
};
const addFish = (
ingredients: string[]
): E.Either<IngredientsOverflowError, NonEmptyArray<string>> => {...};
const addOmelette = (
ingredients: string[]
): E.Either<IngredientsOverflowError, NonEmptyArray<string>> => {...};
const makeCroissants = (): E.Either<IngredientsOverflowError, Croissant> => {
const croissant = {
name: 'Tasty Croissant',
ingredients: [],
tasteRating: 4.8
};
const newIngredients = pipe([], addAvocados, E.chainW(addFishes), E.chainW(addOmelettes));
if(isLeft(newIngredients)) return E.left(newIngredients);
return E.right({
...croissant,
ingredients: newIngredients.right
});
};
Отже, що ми отримали від того, що використали Either?
По-перше, функція makeCroissant явно показує свої побічні ефекти – вона може повернути не тільки об’єкт типу Croissant, а і помилки типу IngredientsMissedError. По-друге, функція makeCroissant стала чистою – контейнер Either це просто значення, яке не містить додаткової логіки. Через це, з ним можна працювати не боячись, що виникне помилка в місці виклику.
Висновок
В цій статті ми розглянули декілька основних модулів з бібліотеки fp-ts. Ця бібліотека надає величезний набір модулів та функцій для роботи з масивами, об’єктами, обробкою помилок та багато іншого. Використання функціонального програмування на typescript робить код читанішим та чистішим, а бібліотека fp-ts спрощує його написання.
З переваг можна виділити:
- часті апдейти бібліотеки
- багато різних модулів та функцій для різних потреб
- велика документація, яка описує всі модулі
З недоліків:
- та ж сама документація, в якій інколи недостатньо прикладів використання певних функцій
Отже, чи варто використовувати ФП у своєму проекті? На справді, не обов’язково робити весь проект на цьому підході. ФП можна використати для окремих модулів, де це дійсно потрібно або може покращити читаність коду та його розуміння.