← Academy Blog

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()));

Виконання коду вище ділиться на три етапи:

  1. Отримуємо інгредієнти викликаючи функцію getIngredients
  2. Викликаємо функцію makeCroissant та передаємо в неї отримані інгредієнти
  3. Викликаємо функцію 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 на простому прикладі:

pipe in action

Отже, вхідні дані в нас - булочка для круасана. На кожному етапі ми додаємо в круасан якийсь інгредієнт, тобто виконуємо певну функцію. Після проходження функції, круасан має в собі певне наповнення та переходить до іншої функції.

Таким чином, після виконання функції 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. Цей модуль має багато функціоналу, але головними його фішками є:

  1. Маппінг по об’єкту за допомогою 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}
  1. Фільтрація полів об’єкту за допомогою 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'}
  1. Видалення конкретного поля за назвою ключа за допомогою deleteAt.
import { deleteAt } from 'fp-ts/Record';

const withoutField = deleteAt('a')({ a: 1, b: 2 });

console.log(withoutField); // { b: 2 }
  1. Валідація кожного поля об’єкту за допомогою every.
import { every } from 'fp-ts/Record';

const isValidFields = every((n: number) => n >= 0)({ a: 1, b: 2 });

console.log(isValidFields); // true
  1. Перетворення об’єкту в масив:
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 спрощує його написання.

З переваг можна виділити:

  • часті апдейти бібліотеки
  • багато різних модулів та функцій для різних потреб
  • велика документація, яка описує всі модулі

З недоліків:

  • та ж сама документація, в якій інколи недостатньо прикладів використання певних функцій

Отже, чи варто використовувати ФП у своєму проекті? На справді, не обов’язково робити весь проект на цьому підході. ФП можна використати для окремих модулів, де це дійсно потрібно або може покращити читаність коду та його розуміння.