Русский
Русский
English
Статистика
Реклама

Ts

Перевод Как мы потерпели неудачу, а затем преуспели в переходе на TypeScript

02.06.2021 18:06:54 | Автор: admin

К старту курса о Fullstack-разработке на Python, где также рассматривается TypeScript, мы перевели статью о миграции в Heap.io компании, которая предоставляет платформу аналитики продуктов, c языка CoffeeScript на TypeScript; TS в Heap.io начали использовать более 4 лет назад. Несмотря на широкое предпочтение TypeScript среди инженеров, миграция была медленной, а чёткого пути к 100 % кода TS не было.


На самом деле, если цель состояла в том, чтобы полностью переключиться на TS, мы двигались в неправильном направлении. Да, код на TS добавлялся, но количество кода на CoffeeScript росло быстрее. TypeScript и CoffeeScript нацелены на один и тот же рантайм Node.js, который, как ожидалось, должен был облегчить переход, но, несмотря на желание большого числа разработчиков перейти на TypeScript, мы не набрали большого импульса и не направлялись к будущему без CoffeeScript.

В начале 2019 года мы возобновили наши усилия по переходу с CoffeeScript на TypeScript. На этот раз мы решили: чтобы вдохнуть жизнь в нашу миграцию, от нас потребуется переосмысление стратегии преобразования существующего кода. Это привело нас к новому набору руководящих принципов. Следуя им, мы превратили то, что казалось трудноразрешимой задачей, в управляемый, хорошо понятный процесс и сумели значительно изменить форму кривых на графике:

Количество строк кода в разработкеКоличество строк кода в разработке

Миграция стека в равной степени касается и технологий, и людей

Самое важное, что мы осознали в ходе этого нового процесса, заключалось в том, что успешная миграция должна быть сосредоточена не только вокруг технологий, но и вокруг людей. Как инженеров нас, как правило, привлекают технические мотивы и (особенно) детали миграции: нам всем нравится идея обрести уверенность в нашем коде, перейдя на TypeScript, а также мы рады потратить часы на то, чтобы точно выяснить, как настроить конфигурацию TypeScript.

Как оказалось, более важные соображения лежат на стороне уравнения с людьми: как заставить наших коллег принять новую парадигму? Этот вопрос поднимает множество других. Например: как сделать разработчиков счастливыми и продуктивными в этой новой модели? Какие барьеры или трения мы можем устранить, чтобы преимущества новой парадигмы стали очевидными? Ответы на эти вопросы помогли нам разработать новый процесс миграции, которым сегодня очень гордимся.

Новый опыт разработки должен предлагать очевидное улучшение

Мы быстро поняли, что для того, чтобы ввести команду в новую парадигму, наши разработчики должны были чувствовать, что они будут более продуктивными, когда будут работать на TS. Если бы команда рассматривала это изменение просто как нейтральный сдвиг между синтаксисами, мы бы никогда не получили общее согласие. Если бы переключение не сделало каждый их день продуктивнее, инерция победила бы даже в случае предпочтения инженером типизированного кода.

Мы начали с определения областей кодовой базы, которые в случае преобразования приведут к существенному повышению производительности, зная, что стратегическое преобразование файлов будет привлекательнее произвольного преобразования. Например, наш слой доступа к данным (ORM) покрывает всё, и большинство файлов каким-то образом работают с ORM. Введение типов на уровне доступа к данным было множителем всей работы; почти каждый преобразованный в будущем файл выиграет от хорошо типизированных моделей баз данных и утилит.

Кроме того, мы сделали приоритетом оснастку и конфигурацию. Большинство разработчиков использовали один из нескольких редакторов, поэтому мы создали конфигурации редактора, которые будут работать сразу. Мы также добавили конфигурации отладки, облегчающие установку точек останова и пошаговое выполнение кода.

Наконец, мы сошлись на наборе согласованных правил линтинга, которые позволили нам писать код в едином стиле по всей организации и сделать переход более удобным.

Когда команды начали видеть плоды этих усилий по преобразованию, весь проект получил одобрение, и импульс к переходу увеличился. Когда наши инженеры начали рассматривать доступ к типизированным данным как незаменимый инструмент, они стали лучше понимать, как подготовить к преобразованию другие части кода.

Технические барьеры нужно ломать

Когда мы начали анализировать шаблоны внедрения TypeScript, стало ясно, что использование TypeScript для наших инженеров не было простым, им часто приходилось импортировать специальные утилиты (ts-node/register) или создавать промежуточные файлы CoffeeScript, которые не делали ничего, кроме импорта их эквивалентов TypeScript. Короче говоря, история взаимодействия языков существовала, но требовала много бойлерплейта и слишком много проб и ошибок.

Были ли эти трения основным блокирующим миграцию фактором? Трудно сказать, но мы знаем, что путь наименьшего сопротивления даёт большую силу, и, если мы хотим, чтобы люди переключались, нам нужно сделать TypeScript простым и очевидным выбором.

Чтобы добиться этого, мы отдавали приоритет усилиям, которые позволили бы разработчикам писать на TS в любом компоненте или сервисе. Будь то бэкенд, фронтенд, скрипты или задачи devops, мы хотели, чтобы наши инженеры могли писать код в TypeScript и чтобы он просто работал. В итоге мы прописали переменную среды NODE_OPTIONSс -r ts-node/register, чтобы существующие (использующие команду coffee для запуска файлов CoffeeScript) рабочие процессы также продолжали работать.

Преобразование должно быть простым, безопасным и автоматизированным

Миграция на другой язык может быть рискованной: то, что может показаться эквивалентным синтаксисом между CoffeeScript и ES6/TypeScript, на самом деле может вообще не быть эквивалентным. И разработчики могут рассматривать преобразование как хорошую возможность для рефакторинга, а это ещё хуже; переписывание делает рискованную миграцию ещё более рискованной.

Чтобы снизить риски, нам нужен был дисциплинированный процесс преобразования файлов без введения регрессий, а также нужно было помочь уйти от соблазна сделать больше, чем просто конверсия один языка в другой. Кроме того, процесс должен был быть быстрым.

Мы остановились на процессе из двух этапов это автоматическое преобразование файла CoffeeScript, за которым немедленно следует ручное добавление аннотаций основных типов и изменений, связанных с линтером. Ключевой момент состоял в том, чтобы противостоять искушению рефакторинга кода любым осмысленным способом. Такое преобразование становится механическим применением простых, безопасных правил, не влияющих на поведение во время выполнения.

Для первоначального преобразования мы использовали скрипт, преобразующий файл .coffee в файл .ts. В целях перехода от CoffeeScript к JavaScript ES6 Под капотом работал decaffeinate. Поскольку весь JavaScript ES6 является синтаксически правильным TypeScript, на выходе получался рабочий файл. (Мы обнаружили, что decaffeinate очень зрелый и надёжный инструмент.) В истории Git шаг преобразования представлен одним отдельным коммитом.

Однако работа ещё не была закончена. Мы используем TypeScript в строгом режиме, поэтому была отключена такая функция, как "implicit any". Мы использовали это окно преобразования как возможность создавать аннотации типов для элементов, где вывод типов был невозможен. Также мы избегали использования any в этой фазе, вместо этого выбрав более строгий неизвестный. Цель на этом этапе состояла в том, чтобы внести изменения, которые не приведут к изменению поведения во время выполнения. Мы не занимались никаким рефакторингом, а просто выполняли минимальный объём работы, чтобы привести код в состояние, в котором он компилировался, линтовался и проходил тесты.

Если зависимости модуля уже были преобразованы в TypeScript, усилий нужно было очень мало: о типах в основном заботились с помощью импортированных модулей. Это привело к эффекту снежного кома, когда по мере преобразования большего количества модулей преобразование в целом стало проще и безопаснее.

Этот второй шаг также прошёл отдельным коммитом; такой подход сильно упростил ревью: ревьюер мог легко увидеть, какие изменения были внесены после шага c decafeinate.

Весь процесс был задокументирован в руководстве по преобразованию машинописного текста. Любой разработчик кучи может преобразовать файл и открыть запрос на вытягивание, чтобы он был объединён всего за 5 минут.

#typescript: канал дискуссий и вопросов

Решение проблемы миграции, подобной этой, означает, что вы просите своих товарищей по команде отказаться от эффективного и удобного им метода работы. Идея заключается в том, что новый подход к работе сделает их более продуктивными. Но достижение этой точки требует времени и усилий с их стороны. Последнее, чего мы хотели, это сбой перехода, когда разработчики в конечном счёте будут биться головой о стену из-за сломанных инструментов, запутанных сообщений об ошибках и загадочных ошибок компилятора. Поэтому нашим следующим приоритетом было выяснить, как использовать опыт всей команды.

Для этого мы создали канал #typescript в Slack и гарантировали, что застрявшие разработчики могут выйти из положения. Разработчики, управляющие миграцией, стали отвечать на вопросы и остановились на поиске общих проблем и камней преткновения. Если возникала одна и та же проблема, они знали, что нужно сделать лучше.

Разработчики должны знать, что на любые вопросы о языке и инструментах, которые у них есть, будут даны быстрые ответы. Мы решили, что чемпионы TypeScript должны отдать приоритет ответам, а не собственной работе. Хотя такой подход замедлил их работу, он также устранил ряд потенциально серьёзных препятствий в миграции.

Отслеживание прогресса

С самого начала мы знали, что массовая миграция за одну ночь невозможна и что, скорее всего, для завершения процесса потребуется год или больше. И это было прекрасно: мы решили, что прогресс важнее совершенства.

Отслеживание усилий во времени оказалось полезным. Таким образом возможно было понять, продолжается ли прогресс в миграции. Для визуализации количества строк мы воспользовались Grafana. Вот ещё одна визуализация, показывающая количество файлов во времени:

Уважающее инженеров руководство

К сожалению, одним из факторов, который в наибольшей степени способствует вашему успеху в подобных проектах находится чуть вне вашего контроля, является готовность руководства предоставить вам пространство для выполнения проекта. Проект миграции был направлен снизу вверх: он начался с того, что я представил план руководителям команд и руководителям инженерных подразделений, и после его утверждения нам предоставили свободу выбора наилучшего способа его реализации. Несмотря на то, что мы говорили о включающем сотни тысяч строк переходе, миграция была на 100 % внутренней.

В целом лучшие защитники подобных проектов это, как правило, команды, ежедневно использующие инструмент, в направлении которого идёт миграция. В Heap нам повезло: наше руководство тоже верит в это, а вера поддерживает идею о том, что лидерство наиболее полезно, когда даёт инженерам возможности и уходит в сторону.

Продолжая эту миграцию, мы надеемся продолжать учиться и использовать то, чему мы научились, чтобы ещё больше упростить следующий проект.

Многим людям без большого опыта в программировании кажется, что писать код на статически типизированном языке сложнее, однако здесь мы видим повышение продуктивности программистов после перехода на язык со статической типизацией.

Ситуации могут быть разными, и если вам не хочется ограничивать себя только одним подходом, владеть языками с разными системами типов, то вы можете обратить внимание на наш курс Fullstack-разработчик на Python, где, кроме Python и TS, студенты также изучают чистый JavaScript.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Функциональное программирование на TypeScript полиморфизм родов высших порядков

01.11.2020 20:22:23 | Автор: admin

Привет, Хабр! Меню зовут Юрий Богомолов, и вы (возможно) можете меня знать по моей работе над серией #MonadicMondays в твиттере, по каналу на ютьюбе или статьям на Medium или dev.to. В русскоязычном сегменте интернета очень мало информации по функциональному программированию на TypeScript и одной из лучших экосистем для этого языка библиотеке fp-ts, в экосистему которой я достаточно активно контрибьютил некоторое время назад. Этой статьей я хочу начать рассказ о ФП на TypeScript, и если будет положительный отклик от хабрасообщества, то продолжу серию.


Думаю, ни для кого не станет откровением то, что TypeScript является одним из самых популярных надмножеств JS со строгой типизацией. После включения строгого режима компиляции и настройки линтера на запрет использования any этот язык становится пригодным для промышленной разработки во многих сферах от CMS до банковского и брокерского ПО. Для системы типов TypeScript были даже неофициальные попытки доказательства полноты по Тьюрингу, что позволяет применять продвинутые техники тайп-левел программирования для обеспечения корректности бизнес-логики при помощи техник making illegal states unrepresentable.


Всё вышеперечисленное дало толчок к созданию для TypeScript замечательной библиотеки для функционального программирования fp-ts за авторством итальянского математика Джулио Канти. Одна из первых вещей, с которой сталкивается человек, желающий ее освоить, весьма специфичные определения типов вида Kind<URI, SomeType> или interface SomeKind<F extends URIS> {}. В этой статье я хочу подвести читателя к пониманию всех этих сложностей и показать, что на самом деле всё очень просто и понятно стоит только начать раскручивать этот паззл.


Роды высшего порядка


Когда заходит речь о функциональном программировании, то разработчики на JS обычно останавливаются на композиции чистых функций и написании простых комбинаторов. Немногие заглядывают на территорию функциональной оптики, и практически невозможно встретить заигрывания с фримонадическими API или схемами рекурсии. На деле же все эти конструкции не являются чем-то неподъемно-сложным, и система типов сильно облегчает изучение и понимание. TypeScript как язык обладает достаточно богатыми выразительными возможностями, однако у них есть свой предел, который доставляет неудобства отсутствие родов/кайндов/kinds. Чтобы было понятнее, давайте рассмотрим пример.


Возьмем всем привычный и хорошо изученный массив. Массив, как и список, это структура данных, выражающая идею недетерминированности: в нем может храниться от 0 до N элементов определенного типа A. При этом, если у нас есть функция вида A -> B, мы можем попросить этот массив применить ее с помощью вызова метода .map(), получив на выходе массив того же размера с элементами типа B, следующими в том же порядке, что и в оригинальном массиве:


const as = [1, 2, 3, 4, 5, 6]; // as :: number[]const f = (a: number): string => a.toString();const bs = as.map(f); // bs :: string[]console.log(bs); // => [ '1', '2', '3', '4', '5', '6' ]

Проведем мысленный экперимент. Вынесем функцию map из прототипа массива в отдельный интерфейс. В итоге мы получаем полиморфную по типу входного и выходного типа функцию высшего порядка, которую я сразу сделаю каррированной для простоты дальнейшего чтения:


interface MappableArray {  readonly map: <A, B>(f: (a: A) => B) => (as: A[]) => B[];}

Вроде бы всё хорошо. Но если мы продолжим наш мысленный эксперимент и начнем рассматривать другие структуры данных, то очень быстро поймем, что функцию map можно реализовать для множества (Set), или хэш-таблицы (Map), или дерева, или стека, или Много для чего, в общем. Давайте посмотрим, как будут меняться сигнатуры функций map для упомянутых структур данных:


type MapForSet   = <A, B>(f: (a: A) => B) => (as: Set<A>) => Set<B>;type MapForMap   = <A, B>(f: (a: A) => B) => (as: Map<string, A>) => Map<string, B>;type MapForTree  = <A, B>(f: (a: A) => B) => (as: Tree<A>) => Tree<B>;type MapForStack = <A, B>(f: (a: A) => B) => (as: Stack<A>) => Stack<B>;

Для простоты я зафиксировал у хэш-таблицы Map строковый тип ключа, чтобы подчеркнуть, что мы рассматриваем ее как полиморфную по типу данных, исключая на текущий момент из рассмотрения полиморфизм по типу ключа.


Думаю, вы уже увидели общий паттерн и задумались: как бы можно было абстрагироваться от типа структуры данных и написать некий обобщенный интерфейс Mappable. Для такой абстракции необходимо, чтобы язык полноценно поддерживал роды высшего порядка, то есть чтобы можно было абстрагироваться от конструкторов типов. Переводя на терминологию TypeScript, нужно иметь возможность написать интерфейс, который может в качестве дженерик-аргументов принимать другие дженерик-типы:


interface Mappable<F> {  // Type 'F' is not generic. ts(2315)  readonly map: <A, B>(f: (a: A) => B) => (as: F<A>) => F<B>;}

К сожалению, этот код не скомпилируется, потому что TypeScript не знает, что тип-аргумент F должен быть дженериком. Мы не можем написать как в Scala F<_> или как-либо еще в языке просто нет выразительных средств для этого. Значит ли это, что нужно опустить руки и дублировать код? Нет, на выручку приходит замечательная академическая статья Легковесный полиморфизм родов высшего порядка.


Легковесный полиморфизм родов высшего порядка


Для того, чтобы сэмулировать в TypeScript полиморфизм родов, мы применим технику, которая называется дефункционализация техника перевода программ высшего порядка на язык первого порядка. Проще говоря вызовы функций превращаются в вызов конструкторов данных с аргументами, соответствующими аргументам функций. В дальнейшем такие конструкторы сопоставляются с образцом (pattern-matching) и интерпретируются уже по месту необходимости. Для тех, кто захочет глубже разобраться в теме, советую оригинальную статью Джона Рейнолдса Definitional interpreters for higher-order programming languages, а мы тем временем посмотрим, как эту технику можно применить к эмуляции родов.


Итак, мы хотим выразить следующую идею: есть тип-дженерик Mappable, принимающий в качестве аргумента некую тип-переменную F, которая сама является конструктором типов первого порядка, то есть дженериком, принимающим в качестве аргумента обычный не-полиморфный тип. Применяя технику дефункционализации, мы сделаем следующее:


  1. Тип-переменную F заменим на уникальный идентификатор типа некий строковый литерал, который будет однозначно указывать, какой конструктор типа мы хотим вызвать: 'Array', 'Promise', 'Set', 'Tree' и так далее.
  2. Создадим служебный тип-конструктор Kind<IdF, A>, который будет представлять собой вызов типа F как дженерика с аргументом типа A: Kind<'F', A> ~ F<A>.
  3. Для упрощения интерпретации конструкторов Kind заведем набор типов-словарей, где будут храниться соотношения между идентификатором типа и самим полиморфным типом по одному такому словарю для типов каждой арности.

Посмотрим, как это выглядит на практике:


interface URItoKind<A> {  'Array': Array<A>;} // словарь для типов 1-арности: Array, Set, Tree, Promise, Maybe, Task...interface URItoKind2<A, B> {  'Map': Map<A, B>;} // словарь для типов 2-арности: Map, Either, Bifunctor...type URIS = keyof URItoKind<unknown>; // тип-сумма всех имён типов 1-арностиtype URIS2 = keyof URItoKind2<unknown, unknown>; // все типы 2-арности// и так далее, сколько сочтете нужнымtype Kind<F extends URIS, A> = URItoKind<A>[F];type Kind2<F extends URIS2, A> = URItoKind2<A>[F];// и так далее

Остается дело за малым: дать возможность пополнять словари URItoKindN любым программистам, а не только авторам библиотеки, в которой применяется эта техника. И тут на выручку приходит замечательная возможность TypeScript, которая называется дополнением модулей (module augmentation). Благодаря ней нам достаточно будет разместить код с дефункционализированными родами в основной библиотеке, а из пользовательского кода определение типа высшего порядка будет простым:


type Tree<A> = ...declare module 'my-lib/path/to/uri-dictionaries' {  interface URItoKind<A> {    'Tree': Tree<A>;  }}type Test1 = Kind<'Tree', string> // сразу же выведется в Tree<string>

Обратно к Mappable


Теперь мы можем определить наш тип Mappable по-настоящему полиморфно для любых 1-арных конструкторов, и реализовать его экземпляры для разных структур данных:


interface Mappable<F extends URIS> {  readonly map: <A, B>(f: (a: A) => B) => (as: Kind<F, A>) => Kind<F, B>;}const mappableArray: Mappable<'Array'> = {  // здесь `as` будет иметь тип A[], без какого-либо упоминания служебного конструктора `Kind`:  map: f => as => as.map(f)};const mappableSet: Mappable<'Set'> = {  // немного нечестно  можно сделать эффективнее, перебирая итератор для множества вручную,  // но цель этой статьи не сделать максимально эффективную реализацию, а объяснить концепцию  map: f => as => new Set(Array.from(as).map(f))};// здесь я предположу, что Tree  обычный индуктивный тип с двумя конструкторами: листом и узлом,// в листах хранятся данные, в узлах хранится набор поддеревьев:const mappableTree: Mappable<'Tree'> = {  map: f => as => {    switch (true) {      case as.tag === 'Leaf': return f(as.value);      case as.tag === 'Node': return node(as.children.map(mappableTree.map(f)));    }  }};

Наконец, я могу сорвать маску с типа Mappable и сказать, что он называется Functor. Функтор состоит из типа T и операции fmap, которая позволяет при помощи функции A => B преобразовать T<A> в T<B>. Еще можно сказать, что функтор поднимает функцию A => B в некий вычислительный контекст T (этот взгляд очень пригодится в дальнейшем, когда будем разбирать тройку Reader/Writer/State).


Экосистема fp-ts


Собственно, идея дефункционализации и легковесного полиморфизма родов высшего порядка стала ключевой для библиотеки fp-ts. Джулио написал прагматичный и лаконичный гайд о том, как определять свои типы высшего порядка: https://gcanti.github.io/fp-ts/guides/HKT.html. Поэтому нет нужды каждый раз применять дефункциолизацию в своих программах достаточно подключить fp-ts и положить идентификаторы типов в словари URItoKind/URItoKind2/URItoKind3, находящиеся в модуле fp-ts/lib/HKT.


В экосистеме fp-ts есть много замечательных библиотек:


  • io-ts библиотека для написания рантайм-валидаторов типов с синтаксисом, максимально близким к синтаксису типов TS
  • parser-ts библиотека парсерных комбинаторов, эдакий parsec на минималках
  • monocle-ts библиотека для функциональной оптики, порт скаловской библиотеки monocle на TS
  • remote-data-ts библиотека с контейнерным типом RemoteData, существенно упрощающим безопасную обработку данных на фронте
  • retry-ts библитека с комбинаторами разных стратегий повтора монадических операций
  • elm-ts микро-фреймворк для программирования в духе Elm Architecture на TS
  • waveguide, matechs-effect системы очень мощных алгебраических эффектов для TS, вдохновленных ZIO

Ну и мои библиотеки из ее экосистемы:


  • circuit-breaker-monad паттерн Circuit Breaker с монадическим интерфейсом
  • kleisli-ts библиотека для программирования с помощью стрелок Клейсли, вдохновленная ранним дизайном ZIO
  • fetcher-ts враппер вокруг fetch, поддерживающий валидацию ответа сервера с помощью типов io-ts
  • alga-ts порт замечательной библиотеки для описания алгебраических графов alga на TS



На этом введение я бы хотел завершить. Напишите, пожалуйста, в комментариях, насколько такой материал интересен лично вам. Я сделал уже несколько итераций преподавания этого материала, и каждый раз находил моменты, которые можно улучшить. Учитывая техническую продвинутость аудитории Хабра, возможно, нет смысла объяснять на Mappable/Chainable и т.д., а сразу называть вещи своими именами функтор, монада, аппликатив? Пишите, буду рад пообщаться в комментариях.

Подробнее..

Функциональное программирование на TypeScript Option и Either

28.02.2021 16:06:27 | Автор: admin

Предыдущие статьи цикла:


  1. Полиморфизм родов высших порядков
  2. Паттерн класс типов



В предыдущей статье мы рассмотрели понятие класса типов (type class) и бегло познакомились с классами типов функтор, монада, моноид. В этой статье я обещал подойти к идее алгебраических эффектов, но решил всё-таки написать про работу с nullable-типами и исключительными ситуациями, чтобы дальнейшее изложение было понятнее, когда мы перейдем к работе с задачами (tasks) и эффектами. Поэтому в этой статье, всё еще рассчитанной на начинающих ФП-разработчиков, я хочу поговорить о функциональном подходе к решению некоторых прикладных проблем, с которыми приходится иметь дело каждый день.


Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.


Стало уже некоторым моветоном цитировать Тони Хоара с его ошибкой на миллиард введению в язык ALGOL W понятия нулевого указателя. Эта ошибка, как опухоль, расползлась по другим языкам C, C++, Java, и, наконец, JS. Возможность присвоения переменной любого типа значения null приводит к нежелательным побочным эффектам при попытке доступа по этому указателю среда исполнения выбрасывает исключение, поэтому код приходится обмазывать логикой обработки таких ситуаций. Думаю, вы все встречали (а то и писали) лапшеобразный код вида:


function foo(arg1, arg2, arg3) {  if (!arg1) {    return null;  }  if (!arg2) {    throw new Error("arg2 is required")  }  if (arg3 && arg3.length === 0) {    return null;  }  // наконец-то начинается бизнес-логика, использующая arg1, arg2, arg3}

TypeScript позволяет снять небольшую часть этой проблемы с флагом strictNullChecks компилятор не позволяет присвоить не-nullable переменной значение null, выбрасывая ошибку TS2322. Но при этом из-за того, что тип never является подтипом всех других типов, компилятор никак не ограничивает программиста от выбрасывания исключения в произвольном участке кода. Получается до смешного нелепая ситуация, когда вы видите в публичном API библиотеки функцию add :: (x: number, y: number) => number, но не можете использовать её с уверенностью из-за того, что её реализация может включать выбрасывание исключения в самом неожиданном месте. Более того, если в той же Java метод класса можно пометить ключевым словом throws, что обяжет вызывающую сторону поместить вызов в try-catch или пометить свой метод аналогичной сигнатурой цепочки исключений, то в TypeScript что-то, кроме (полу)бесполезных JSDoc-аннотаций, придумать для типизации выбрасываемых исключений сложно.


Также стоит отметить, что зачастую путают понятия ошибки и исключительной ситуации. Мне импонирует разделение, принятое в JVM-мире: Error (ошибка) это проблема, от которой нет возможности восстановиться (скажем, закончилась память); exception (исключение) это особый случай поток исполнения программы, который необходимо обработать (скажем, произошло переполнение или выход за границы массива). В JS/TS-мире мы выбрасываем не исключения, а ошибки (throw new Error()), что немного запутывает. В последующем изложении я буду говорить именно об исключениях как о сущностях, генерируемых пользовательским кодом и несущими вполне конкретную семантику исключительная ситуация, которую было бы неплохо обработать.

Функциональные подходы к решению этих двух проблем ошибки на миллиард и исключительных ситуаций мы сегодня и будем рассматривать.


Option<A> замена nullable-типам


В современном JS и TS для безопасной работы с nullable-типам есть возможность использовать optional chaining и nullish coalescing. Тем не менее, эти синтаксические возможности не покрывают всех потребностей, с которыми приходится сталкиваться программисту. Вот пример кода, который нельзя переписать с помощью optional chaining только путём монотонной работы с if (a != null) {}, как в Go:


const getNumber = (): number | null => Math.random() > 0.5 ? 42 : null;const add5 = (n: number): number => n + 5;const format = (n: number): string => n.toFixed(2);const app = (): string | null => {  const n = getNumber();  const nPlus5 = n != null ? add5(n) : null;  const formatted = nPlus5 != null ? format(nPlus5) : null;  return formatted;};

Тип Option<A> можно рассматривать как контейнер, который может находиться в одном из двух возможных состояний: None в случае отсутствия значения, и Some в случае наличия значения типа A:


type Option<A> = None | Some<A>;interface None {  readonly _tag: 'None';}interface Some<A> {  readonly _tag: 'Some';  readonly value: A;}

Оказалось, что для такой структуры можно определить экземпляры функтора, монады и некоторых других. Для сокращения кодовых выкладок я покажу реализацию класса типов монада, а дальше мы проведем параллели между императивным кодом с обработкой ошибок обращения к null, приведенным выше, и кодом в функциональном стиле.


import { Monad1 } from 'fp-ts/Monad';const URI = 'Option';type URI = typeof URI;declare module 'fp-ts/HKT' {  interface URItoKind<A> {    readonly [URI]: Option<A>;  }}const none: None = { _tag: 'None' };const some = <A>(value: A) => ({ _tag: 'Some', value });const Monad: Monad1<URI> = {  URI,  // Функтор:  map: <A, B>(optA: Option<A>, f: (a: A) => B): Option<B> => {    switch (optA._tag) {      case 'None': return none;      case 'Some': return some(f(optA.value));    }  },  // Аппликативный функтор:  of: some,  ap: <A, B>(optAB: Option<(a: A) => B>, optA: Option<A>): Option<B> => {    switch (optAB._tag) {      case 'None': return none;      case 'Some': {        switch (optA._tag) {          case 'None': return none;          case 'Some': return some(optAB.value(optA.value));        }      }    }  },  // Монада:  chain: <A, B>(optA: Option<A>, f: (a: A) => Option<B>): Option<B> => {    switch (optA._tag) {      case 'None': return none;      case 'Some': return f(optA.value);    }  }};

Как я писал в предыдущей статье, монада позволяет организовывать последовательные вычисления. Интерфейс монады один и тот же для разных типов высшего порядка это наличие функций chain (она же bind или flatMap в других языках) и of (pure или return).


Если бы в JS/TS был синтаксический сахар для более простой работы с интерфейсом монады, как в Haskell или Scala, то мы единообразно работали бы с nullable-типам, промисами, кодом с исключениями, массивами и много чем еще вместо того, чтобы раздувать язык большим количеством точечных (и, зачастую, частичных) решений частных случаев (Promise/A+, потом async/await, потом optional chaining). К сожалению, подведение под основу языка какой-либо математической базы не является приоритетным направлением работы комитета TC39, поэтому мы работаем с тем, что есть.

Контейнер Option доступен в модуле fp-ts/Option, поэтому я просто импортирую его оттуда, и перепишу императивный пример выше в функциональном стиле:


import { pipe, flow } from 'fp-ts/function';import * as O from 'fp-ts/Option';import Option = O.Option;const getNumber = (): Option<number> => Math.random() > 0.5 ? O.some(42) : O.none;// эти функции модифицировать не нужно!const add5 = (n: number): number => n + 5;const format = (n: number): string => n.toFixed(2);const app = (): Option<string> => pipe(  getNumber(),  O.map(n => add5(n)), // или просто O.map(add5)  O.map(format));

Благодаря тому, что один из законов для функтора подразумевает сохранение композиции функций, мы можем переписать app еще короче:


const app = (): Option<string> => pipe(  getNumber(),  O.map(flow(add5, format)),);

N.B. В этом крохотном примере не нужно смотреть на конкретную бизнес-логику (она умышленно сделана примитивной), а важно подметить одну вещу касательно функциональной парадигмы в целом: мы не просто использовали функцию по-другому, мы абстрагировали общее поведение для вычислительного контекста контейнера Option (изменение значения в случае его наличия) от бизнес-логики (работа с числами). При этом само вынесенное в функтор/монаду/аппликатив/etc поведение можно переиспользовать в других местах приложения, получив один и тот же предсказуемый порядок вычислений в контексте разной бизнес-логики. Как это сделать мы рассмотрим в последующих статьях, когда будем говорить про Free-монады и паттерн Tagless Final. С моей точки зрения, это одна из сильнейших сторон функциональной парадигмы отделение общих абстрактных вещей с последующим переиспользованием их для композиции в более сложные структуры.

Either<E, A> вычисления, которые могут идти двумя путями


Теперь поговорим про исключения. Как я уже писал выше, исключительная ситуация это нарушение обычного потока исполнения логики программы, на которое как-то необходимо среагировать. При этом выразительных средств в самом языке у нас нет но мы сможем обойтись структурой данных, несколько схожей с Option, которая называется Either:


type Either<E, A> = Left<E> | Right<A>;interface Left<E> {  readonly _tag: 'Left';  readonly left: E;}interface Right<A> {  readonly _tag: 'Right';  readonly right: A;}

Тип Either<E, A> выражает идею вычислений, которые могут пойти по двум путям: левому, завершающемуся значением типа E, или правому, завершающемуся значением типа A. Исторически сложилось соглашение, в котором левый путь считается носителем данных об ошибке, а правый об успешном результате. Для Either точно так же можно реализовать множество классов типов функтор/монаду/альтернативу/бифунктор/etc, и всё это уже есть реализовано в fp-ts/Either. Я же приведу реализацию интерфейса монады для общей справки:


import { Monad2 } from 'fp-ts/Monad';const URI = 'Either';type URI = typeof URI;declare module 'fp-ts/HKT' {  interface URItoKind2<E, A> {    readonly [URI]: Either<E, A>;  }}const left = <E, A>(e: E) => ({ _tag: 'Left', left: e });const right = <E, A>(a: A) => ({ _tag: 'Right', right: a });const Monad: Monad2<URI> = {  URI,  // Функтор:  map: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => B): Either<E, B> => {    switch (eitherEA._tag) {      case 'Left':  return eitherEA;      case 'Right': return right(f(eitherEA.right));    }  },  // Аппликативный функтор:  of: right,  ap: <E, A, B>(eitherEAB: Either<(a: A) => B>, eitherEA: Either<A>): Either<B> => {    switch (eitherEAB._tag) {      case 'Left': return eitherEAB;      case 'Right': {        switch (eitherEA._tag) {          case 'Left':  return eitherEA;          case 'Right': return right(eitherEAB.right(eitherEA.right));        }      }    }  },  // Монада:  chain: <E, A, B>(eitherEA: Either<E, A>, f: (a: A) => Either<E, B>): Either<E, B> => {    switch (eitherEA._tag) {      case 'Left':  return eitherEA;      case 'Right': return f(eitherEA.right);    }  }};

Рассмотрим пример императивного кода, который бросает исключения, и перепишем его в функциональном стиле. Классической предметной областью, на которой демонстрируют работу с Either, является валидация. Предположим, мы пишем API регистрации нового аккаунта, принимающий email пользователя и пароль, и проверяющий следующие условия:


  1. Email содержит знак @;
  2. Email хотя бы символ до знака @;
  3. Email содержит домен после знака @, состоящий из не менее 1 символа до точки, самой точки и не менее 2 символов после точки;
  4. Пароль имеет длину не менее 1 символа.

После завершения всех проверок возвращается некий аккаунт, либо же ошибка валидации. Типы данных, составляющий наш домен, описываются очень просто:


interface Account {  readonly email: string;  readonly password: string;}class AtSignMissingError extends Error { }class LocalPartMissingError extends Error { }class ImproperDomainError extends Error { }class EmptyPasswordError extends Error { }type AppError =  | AtSignMissingError  | LocalPartMissingError  | ImproperDomainError  | EmptyPasswordError;

Императивную реализацию можно представить как-нибудь так:


const validateAtSign = (email: string): string => {  if (!email.includes('@')) {    throw new AtSignMissingError('Email must contain "@" sign');  }  return email;};const validateAddress = (email: string): string => {  if (email.split('@')[0]?.length === 0) {    throw new LocalPartMissingError('Email local-part must be present');  }  return email;};const validateDomain = (email: string): string => {  if (!/\w+\.\w{2,}/ui.test(email.split('@')[1])) {    throw new ImproperDomainError('Email domain must be in form "example.tld"');  }  return email;};const validatePassword = (pwd: string): string => {  if (pwd.length === 0) {    throw new EmptyPasswordError('Password must not be empty');  }  return pwd;};const handler = (email: string, pwd: string): Account => {  const validatedEmail = validateDomain(validateAddress(validateAtSign(email)));  const validatedPwd = validatePassword(pwd);  return {    email: validatedEmail,    password: validatedPwd,  };};

Сигнатуры всех этих функций обладают той самой чертой, о которой я писал в начале статьи они никак не сообщают использующему этот API программисту, что они выбрасывают исключения. Давайте перепишем этот код в функциональном стиле с использованием Either:


import * as E from 'fp-ts/Either';import { pipe } from 'fp-ts/function';import * as A from 'fp-ts/NonEmptyArray';import Either = E.Either;

Переписать императивный код, выбрасывающий исключения, на код с Either'ами достаточно просто в месте, где был оператор throw, пишется возврат левого (Left) значения:


// Было:const validateAtSign = (email: string): string => {  if (!email.includes('@')) {    throw new AtSignMissingError('Email must contain "@" sign');  }  return email;};// Стало:const validateAtSign = (email: string): Either<AtSignMissingError, string> => {  if (!email.includes('@')) {    return E.left(new AtSignMissingError('Email must contain "@" sign'));  }  return E.right(email);};// После упрощения через тернарный оператор и инверсии условия:const validateAtSign = (email: string): Either<AtSignMissingError, string> =>  email.includes('@') ?    E.right(email) :    E.left(new AtSignMissingError('Email must contain "@" sign'));

Аналогичным образом переписываются другие функции:


const validateAddress = (email: string): Either<LocalPartMissingError, string> =>  email.split('@')[0]?.length > 0 ?    E.right(email) :    E.left(new LocalPartMissingError('Email local-part must be present'));const validateDomain = (email: string): Either<ImproperDomainError, string> =>  /\w+\.\w{2,}/ui.test(email.split('@')[1]) ?    E.right(email) :    E.left(new ImproperDomainError('Email domain must be in form "example.tld"'));const validatePassword = (pwd: string): Either<EmptyPasswordError, string> =>  pwd.length > 0 ?     E.right(pwd) :     E.left(new EmptyPasswordError('Password must not be empty'));

Остается теперь собрать всё воедино в функции handler. Для этого я воспользуюсь функцией chainW это функция chain из интерфейса монады, которая умеет делать расширение типов (type widening). Вообще, есть смысл рассказать немного о конвенции именования функций, принятой в fp-ts:


  • Суффикс W означает type Widening расширение типов. Благодаря этому можно в одну цепочку поместить функции, возвращающие разные типы в левых частях Either/TaskEither/ReaderTaskEither и прочих структурах, основанных на типах-суммах:


    // Предположим, есть некие типы A, B, C, D, типы ошибок E1, E2, E3, // и функции foo, bar, baz, работающие с ними:declare const foo: (a: A) => Either<E1, B>declare const bar: (b: B) => Either<E2, C>declare const baz: (c: C) => Either<E3, D>declare const a: A;// Не скомпилируется, потому что chain ожидает мономорфный по типу левой части Either:const willFail = pipe(  foo(a),  E.chain(bar),  E.chain(baz));// Скомпилируется корректно:const willSucceed = pipe(  foo(a),  E.chainW(bar),  E.chainW(baz));
    

  • Суффикс T может означать две вещи либо Tuple (например, как в функции sequenceT), либо монадные трансформеры (как в модулях EitherT, OptionT и тому подобное).
  • Суффикс S означает structure например, как в функциях traverseS и sequenceS, которые принимают на вход объект вида ключ функция преобразования.
  • Суффикс L раньше означал lazy, но в последних релизах от него отказались в пользу ленивости по умолчанию.

Эти суффиксы могут объединяться например, как в функции apSW: это функция ap из класса типов Apply, которая умеет делать type widening и принимает на вход структуру, по ключам которой итерирует.


Возвращаемся к написанию handler. Я использую chainW, чтобы собрать тип возможных ошибок как тип-сумму AppError:


const handler = (email: string, pwd: string): Either<AppError, Account> => pipe(  validateAtSign(email),  E.chainW(validateAddress),  E.chainW(validateDomain),  E.chainW(validEmail => pipe(    validatePassword(pwd),    E.map(validPwd => ({ email: validEmail, password: validPwd })),  )),);

Что же мы получили в результате такого переписывания? Во-первых, функция handler явно сообщает о своих побочных эффектах она может не только вернуть объект типа Account, но и вернуть ошибки типов AtSignMissingError, LocalPartMissingError, ImproperDomainError, EmptyPasswordError. Во-вторых, функция handler стала чистой контейнер Either это просто значение, не содержащее дополнительной логики, поэтому с ним можно работать без боязни, что произойдет что-то нехорошее в месте вызова.


NB: Разумеется, эта оговорка просто соглашение. TypeScript как язык и JavaScript как рантайм никак нас не ограничивают от того, чтобы написать код в духе:
const bad = (cond: boolean): Either<never, string> => {  if (!cond) {    throw new Error('COND MUST BE TRUE!!!');  }  return E.right('Yay, it is true!');};


Понятное дело, что в приличном обществе за такой код бьют канделябром по лицу на код ревью, а после просят переписать с использованием безопасных методов и комбинаторов. Скажем, если вы работаете со сторонними синхронными функциями, их стоит оборачивать в Either/IOEither с помощью комбинатора tryCatch, если с промисами через TaskEither.tryCatch и так далее.

У императивного и функционального примеров есть один общий недостаток они оба сообщают только о первой встреченной ошибке. То самое отделение поведения структуры данных от бизнес-логики, о котором я писал в секции про Option, позволит нам написать вариант программы, собирающей все ошибки, с минимальными усилиями. Для этого понадобится познакомиться с некоторыми новыми концепциями.


Есть у Either брат-близнец тип Validation. Это точно такой же тип-сумма, у которого правая часть означает успех, а левая ошибку валидации. Нюанс заключается в том, что Validation требует, чтобы для левой части типа E была определена операция contact :: (a: E, b: E) => E из класса типов Semigroup. Это позволяет использовать Validation вместо Either в задачах, где необходимо собирать все возможные ошибки. Например, мы можем переписать предыдущий пример (функцию handler) так, чтобы собрать все возможные ошибки валидации входных данных, не переписывая при этом остальные функции валидации (validateAtSign, validateAddress, validateDomain, validatePassword).


Расскажу пару слов об алгебраических структурах, умеющих объединять два элемента

Они выстраиваюся в следующую иерархию:


  • Magma (Магма), или группоид базовый класс типов, определяющий операцию contact :: (a: A, b: A) => A. На эту операцию не налагается никаких других ограничений.
  • Если к магме добавить ограничение ассоциативности для операции concat, получим полугруппу (Semigroup). На практике оказывается, что полугруппы более полезны, так как чаще всего работа ведется со структурами, в которых порядок элементов имеет значимость вроде массивов или деревьев.
  • Если к полугруппе добавить единицу (unit) значение, которое можно сконструировать в любой момент просто так, получим моноид (Monoid).
  • Наконец, если к моноиду добавим операцию inverse :: (a: A) => A, которая позволяет получить для произвольного значения его инверсию, получим группу (Group).

Groupoid hierarchy
Детальнее об иерархии алгебраических структур можно почитать в вики.


Иерархию классов типов, соответствующих таким алгебраическим структурам, можно продолжать и дальше: в библиотеке fp-ts определены классы типов Semiring, Ring, HeytingAlgebra, BooleanAlgebra, разного рода решётки (lattices) и т.п.


Нам для решения задачи получения списка всех ошибок валидации понадобится две вещи: тип NonEmptyArray (непустой массив) и полугруппа, которую можно определить для этого типа. Вначале напишем вспомогательную функцию lift, которая будет переводить функцию вида A => Either<E, B> в функцию A => Either<NonEmptyArray<E>, B>:


const lift = <Err, Res>(check: (a: Res) => Either<Err, Res>) => (a: Res): Either<NonEmptyArray<Err>, Res> => pipe(  check(a),  E.mapLeft(e => [e]),);

Для того, чтобы собрать все ошибки в большой кортеж, я возпользуюсь функцией sequenceT из модуля fp-ts/Apply:


import { sequenceT } from 'fp-ts/Apply';import NonEmptyArray = A.NonEmptyArray;const NonEmptyArraySemigroup = A.getSemigroup<AppError>();const ValidationApplicative = E.getApplicativeValidation(NonEmptyArraySemigroup);const collectAllErrors = sequenceT(ValidationApplicative);const handlerAllErrors = (email: string, password: string): Either<NonEmptyArray<AppError>, Account> => pipe(  collectAllErrors(    lift(validateAtSign)(email),    lift(validateAddress)(email),    lift(validateDomain)(email),    lift(validatePassword)(password),  ),  E.map(() => ({ email, password })),);

Если запустим эти функции с одним и тем же некорректным примером, содержащим более одной ошибки, то получим разное поведение:


> handler('user@host.tld', '123'){ _tag: 'Right', right: { email: 'user@host.tld', password: '123' } }> handler('user_host', ''){ _tag: 'Left', left: AtSignMissingError: Email must contain "@" sign }> handlerAllErrors('user_host', ''){  _tag: 'Left',  left: [    AtSignMissingError: Email must contain "@" sign,    ImproperDomainError: Email domain must be in form "example.tld",    EmptyPasswordError: Password must not be empty  ]}

В этих примерах я хочу обратить ваше внимание на то, что мы получаем различную обработку поведения функций, составляющих костяк нашей бизнес-логики, не затрагивая при этом сами функции валидации (т.е. ту самую бизнес-логику). Функциональная парадигма как раз и заключается в том, чтобы из наличествующих строительных блоков собирать то, что требуется в текущий момент без необходимости сложного рефакторинга всей системы.



На этом текущую статью я заканчиваю, а в следующей будем говорить уже про Task, TaskEither и ReaderTaskEither. Они позволят нам подойти к идее алгебраических эффектов и понять, что это даёт в плане удобства разработки.

Подробнее..

Функциональное программирование на TypeScript задачи (tasks) как альтернатива промисам

24.03.2021 20:12:56 | Автор: admin

Предыдущие статьи цикла:


  1. Полиморфизм родов высших порядков
  2. Паттерн класс типов
  3. Option и Either как замены nullable-типам и исключениям



В предыдущей статье мы рассмотрели типы Option и Either, которые предоставляют функциональную замену nullable-типам и выбрасыванию исключений. В этой статье я хочу поговорить о ленивой функциональной замене промисам задачам (tasks). Они позволят нам подойти к понятию алгебраических эффектов, которые я подробно рассмотрю в следующих статьях.


Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.


Promise/A+, который мы потеряли заслужили


В далеком 2013 году Брайан МакКенна написал пост о том, что следовало бы изменить в спецификации Promise/A+ для того, чтобы промисы соответствовали монадическому интерфейсу. Эти изменения были незначительные, но очень важные с точки зрения соблюдения теоретико-категорных законов для монады и функтора. Итак, Брайан МакКенна предлагал:


  1. Добавить статический метод конструирования промиса Promise.point:
    Promise.point = function(a) {  // ...};
    
  2. Добавить метод onRejected для обработки состояния неудачи:
    Promise.prototype.onRejected = function(callback) {  // ...};
    
  3. Сделать так, чтобы Promise.prototype.then принимал только один коллбэк, и этот коллбэк обязательно должен возвращать промис:
    Promise.prototype.then = function(onFulfilled) {  // ...};
    
  4. Наконец, сделать промис ленивым, добавив метод done:
    Promise.prototype.done = function() {  // ...};
    

Эти изменения позволили бы получить простое расширяемое API, которое в дальнейшем позволило бы элегантно отделять поведение контекста вычислений от непосредственной бизнес-логики скажем, так, как это сделано в Haskell с его do-нотацией, или в Scala с for comprehension. К сожалению, так называемые прагматики в лице Доменика Дениколы и нескольких других контрибьюторов отвергли эти предложения, поэтому промисы в JS так и остались невнятным энергичным бастардом, которого достаточно проблематично использовать в идиоматичном ФП-коде, предполагающим equational reasoning и соблюдение принципа ссылочной прозрачности. Тем не менее, благодаря достаточно простому трюку можно сделать из промисов законопослушную абстракцию, для которой можно реализовать экземпляры функтора, аппликатива, монады и много чего еще.


Task<A> ленивый промис


Первой абстракций, которая позволит сделать промис законопослушным, является Task. Task<A> это примитив асинхронных вычислений, который олицетворяет задачу, которая всегда завершается успешно со значением типа A (то есть не содержит выразительных средств для представления ошибочного состояния):


// Task  ленивый примитив асинхронных вычисленийtype Task<A> = () => Promise<A>;// Уникальный идентификатор ресурса  тэг типа (type tag)const URI = 'Task';type URI = typeof URI;// Определение Task как типа высшего порядка (higher-kinded type)declare module 'fp-ts/HKT' {  interface URItoKind<A> {    [URI]: Task<A>;  }}

Для Task можно определить экземпляры классов типов Functor, Apply, Applicative, Monad. Обратите внимание, как один из самых простых классов типов функтор порождает структуры, обладающие всё более и более сложным поведением.


N.B.: Также оговорюсь, что для простоты реализации код по обработке состояния rejected в промисах, использующихся внутри Task, не пишется подразумевается, что конструирование экземпляров Task происходит при помощи функций-конструкторов, а не ad hoc.

Функтор позволяет преобразовывать значение, которое будет возвращено задачей, из типа A в тип B при помощи чистой функции:


const Functor: Functor1<URI> = {  URI,  map: <A, B>(    taskA: Task<A>,     transform: (a: A) => B  ): Task<B> => async () => {    const prevResult = await taskA();    return transform(prevResult);  },};

Apply позволяет применять некую функцию преобразования, получающуюся асинхронно, к данным, которые будут возвращены задачей. Для Task можно написать два экземпляра Apply один будет вычислять результат и функцию преобразования последовательно, другой параллельно:


const Apply: Apply1<URI> = {  ...Functor,  ap: <A, B>(    taskA2B: Task<(a: A) => B>,     taskA: Task<A>  ): Task<B> => async () => {    const transformer = await taskA2B();    const prevResult = await taskA();    return transformer(prevResult);  },};const ApplyPar: Apply1<URI> = {  ...Functor,  ap: <A, B>(    taskA2B: Task<(a: A) => B>,     taskA: Task<A>  ): Task<B> => async () => {    const [transformer, prevResult] = await Promise.all([taskA2B(), taskA()]);    return transformer(prevResult);  },};

Аппликативный функтор (аппликатив) позволяет конструировать новые значения некоего типа F, поднимая (lift) их в вычислительный контекст F. В нашем случае аппликатив оборачивает чистое значение в задачу. Для простоты я буду использовать последовательный экземпляр Apply для наследования:


const Applicative: Applicative1<URI> = {  ...Apply,  of: <A>(a: A): Task<A> => async () => a,};

Монада позволяет организовывать последовательные вычисления сначала вычисляется результат предыдущей задачи, после чего полученный результат используется для последующих вычислений. Обратите внимание: хоть мы и можем использовать для определения монады любой экземпляр аппликатива как базирующийся на последовательном Apply, так и на параллельном, функция chain, являющаяся сердцем монады, вычисляется для Task строго последовательно. Это напрямую следует из типов, и, в целом, не является чем-то сложным но я считаю своей обязанностью обратить на это внимание:


const Monad: Monad1<URI> = {  ...Applicative,  chain: <A, B>(    taskA: Task<A>,     next: (a: A) => Task<B>  ): Task<B> => async () => {    const prevResult = await taskA();    const nextTask = next(prevResult);    return nextTask();  },};

N.B.: так как экземпляр монады для Task может наследоваться от одного из двух экземпляров аппликатива параллельного или последовательного, то подставляя нужный экземпляр монады в программы, написанные в стиле Tagless Final, можно получить разное поведение аппликативных операций. Про реализацию стиля Tagless Final на тайпскрипте можно почитать в этом треде #MonadicMondays.

Имея на руках такие выразительные способности, как монада и функтор, можно уже писать простые программы в императивном стиле: делать ветвление, вычислять что-либо рекурсивно. Но для работы над задачами реального мира необходимо уметь выражать ошибочное состояние, и в этом поможет следующая абстракция TaskEither.


TaskEither<E, A> задача, которая может вернуть ошибку


В предыдущей статье мы рассмотрели тип данных Either, который представляет вычисления, которые могут идти по одному из двух путей. Для типа Either можно реализовать экземпляры функтора, монады, альтернативы (Alt + Alternative, позволяет выражать fallback-значения), бифунктора (позволяет модифицировать одновременно как левую, так и правую часть Either) и много чего еще.


Комбинируя Task и Either, мы получаем абстракцию, которая обладает новой семантикой TaskEither<E, A> это асинхронные вычисления, которые могут завершиться успешно со значением типа A или завершиться неудачей с ошибкой типа E. В fp-ts для TaskEither реализован ряд комбинаторов, как то:


  • bracket позволяет безопасно получить (acquire), использовать (use) и утилизировать (release) какой-либо ресурс например, соединение с базой данных или файловый дескриптор. При этом функция release вызовется вне зависмости от того, завершилась ли функция use успехом или неудачей:


    bracket: <E, A, B>(  acquire: TaskEither<E, A>,  use: (a: A) => TaskEither<E, B>,  release: (a: A, e: E.Either<E, B>) => TaskEither<E, void>) => TaskEither<E, B>
    

  • tryCatch оборачивает промис, который может быть отклонен, в промис, который никогда не может быть отклонен и который возвращает Either. Эта функция вместе со следующей функцией taskify один из краеугольных камней для адаптации функций сторонних библиотек к функциональному стилю. Также есть функция tryCatchK, которая умеет работать с функциями от нескольких аргументов:


    tryCatch: <E, A>(  f: Lazy<Promise<A>>,   onRejected: (reason: unknown) => E) => TaskEither<E, A>tryCatchK: <E, A extends readonly unknown[], B>(  f: (...a: A) => Promise<B>,   onRejected: (reason: unknown) => E) => (...a: A) => TaskEither<E, B>
    

  • taskify функция, которая позволяет превратить коллбэк в стиле Node.js в функцию, возвращающую TaskEither. taskify перегружена для оборачивания функций от 0 до 6 аргументов + коллбэк:


    taskify<A, L, R>(  f: (a: A, cb: (e: L | null | undefined, r?: R) => void) => void): (a: A) => TaskEither<L, R>
    


Благодаря тому, что для TaskEither реализованы экземпляры Traversable и Foldable, возможна простая работа по обходу массива задач. Функции traverseArray, traverseArrayWithIndex, sequenceArray и их последовательные вариации traverseSeqArray, traverseSeqArrayWithIndex, sequenceSeqArray позволяют обойти массив задач и получить как результат задачу, чьим результатом является массив результатов. Например, вот как можно написать программу, которая должна прочитать три файла с диска и записать их содержимое в единый новый файл:


import * as fs from 'fs';import { pipe } from 'fp-ts/function';import * as Console from 'fp-ts/Console';import * as TE from 'fp-ts/TaskEither';// Сначала я оберну функции из системного модуля `fs` при помощи `taskify`, сделав их чистыми:const readFile = TE.taskify(fs.readFile);const writeFile = TE.taskify(fs.writeFile);const program = pipe(  // Входная точка  массив задач по чтению трёх файлов с диска:  [readFile('/tmp/file1'), readFile('/tmp/file2'), readFile('/tmp/file3')],  // Для текущей задачи важен порядок обхода массива, поэтому я использую  // последовательную, а не параллельную версию traverseArray:  TE.traverseSeqArray(TE.map(buffer => buffer.toString('utf8'))),  // При помощи функции `chain` из интерфейса монады я организую  // последовательность вычислений:  TE.chain(fileContents =>     writeFile('/tmp/combined-file', fileContents.join('\n\n'))),  // Наконец, в финале я хочу узнать, завершилась ли программа успешно или   // ошибочно, и залогировать это. Тут мне поможет модуль `fp-ts/Console`,  // содержащий чистые функции по работе с консолью:  TE.match(    err => TE.fromIO(Console.error(`An error happened: ${err.message}`)),    () => TE.fromIO(Console.log('Successfully written to combined file')),  ));// Наконец, запускаем нашу чистую программу на выполнение, // выполняя все побочные эффекты:await program();

N.B.: Если обратите внимание, то я пишу про функции, возвращающие TaskEither, как про чистые. В прошлых статьях я вскользь затрагивал эту тему: в функциональном подходе многое строится на создании описания вычислений с последующей интерпретацией их по необходимости. Когда я буду рассказывать про алгебраические эффекты и свободные монады, эта тема будет раскрыта более полно; сейчас же я просто скажу, что Task/TaskEither/ReaderTaskEither/etc. это просто значения, а не запущенные вычисления, поэтому с ними можно обращаться более вольготно, чем с промисами. Именно ленивость Task'ов позволяет им быть настолько удобной и мощной абстракцией. Код, написанный с применением TaskEither, проще рефакторить с помощью принципа ссылочной прозрачности: задачи можно спокойно создавать, отменять и передавать в другие функции.

Казалось бы, TaskEither дает хорошие выразительные способности в типах видно, какой результат и какую ошибку может вернуть функция. Но мы можем пойти еще немного дальше и добавить еще один уровень абстракции Reader.


Reader доступ к неизменному вычислительному контексту


Если мы возьмем тип функции A -> B, и зафиксируем тип аргумента A как неизменный, мы получим структуру, для которой можно определить экземпляры функтора, аппликатива, монады, профунктора, категории и т.п., которую назвали Reader:


// Reader это функция из некоторого окружения типа `E` в значение типа `A`:type Reader<E, A> = (env: E) => A;// Reader является типом высшего порядка, поэтому определим всё необходимое:const URI = 'Reader';type URI = typeof URI;declare module 'fp-ts/HKT' {  interface URItoKind2<E, A> {    readonly [URI]: Reader<E, A>;  }}

Для Reader можно определить экземпляры следующих классов типов:


// Функтор:const Functor: Functor2<URI> = {  URI,  map: <R, A, B>(    fa: Reader<R, A>,     f: (a: A) => B  ): Reader<R, B> => (env) => f(fa(env))};// Apply:const Apply: Apply2<URI> = {  ...Functor,  ap: <R, A, B>(    fab: Reader<R, (a: A) => B>,     fa: Reader<R, A>  ): Reader<R, B> => (env) => {    const fn = fab(env);    const a = fa(env);    return fn(a);  }};// Аппликативный функтор:const Applicative: Applicative2<URI> = {  ...Apply,  of: <R, A>(a: A): Reader<R, A> => (_) => a};// Монада:const Monad: Monad2<URI> = {  ...Applicative,  chain: <R, A, B>(    fa: Reader<R, A>,     afb: (a: A) => Reader<R, B>  ): Reader<R, B> => (env) => {    const a = fa(env);    const fb = afb(a);    return fb(env);  },};

Reader позволяет реализовать интересный паттерн доступ к некоторому неизменному окружению. Предположим, мы хотим, чтобы у приложения был доступ к конфигурации со следующим типом:


interface AppConfig {  readonly host: string; // имя хоста веб-сервера  readonly port: number; // порт, который будет слушать веб-сервер  readonly connectionString: string; // параметры соединения с некоторой БД}

Для упрощения я сделаю типы БД и express алиасами для строковых литералов сейчас мне не так важно, какой бизнес-тип будут возвращать функции; важнее продемонстрировать принципы работы с Reader:


type Database = 'connected to the db';type Express = 'express is listening';// Наше приложение  это *значение типа A*, вычисляемое *в контексте доступа // к конфигурации типа AppConfig*:type App<A> = Reader<AppConfig, A>;

Для начала напишем функцию, которая соединяется с нашим фейковым экспрессом:


const expressServer: App<Express> = pipe(  // `ask` позволяет запросить от окружения значение типа AppConfig.   // Ее реализация тривиальна:  // const ask = <R>(): Reader<R, R> => r => r;  R.ask<AppConfig>(),  // Я использую функтор, чтобы получить доступ к конфигу и что-то сделать   // на его основе  например, залогировать параметры и вернуть значение   // типа `Express`:  R.map(    config => {      console.log(`${config.host}:${config.port}`);      // В реальном приложении здесь нужно выполнять асинхронные операции       // по запуску сервера.      // Мы поговорим о работе с асинхронностью в следующей секции:      return 'express is listening';    },  ),);

Функция databaseConnection работает в контексте конфига и возвращает соединение с фейковой БД:


const databaseConnection: App<Database> = pipe(  // `asks` позволяет запросить значение определенного типа и сразу же   // преобразовать его в какое-то другое  например, здесь я просто достаю   // из конфига строку с параметрами соединения:  R.asks<AppConfig, string>(cfg => cfg.connectionString),  R.map(    connectionString => {      console.log(connectionString);      return 'connected to the db';    },  ),);

Наконец, наше приложение не будет ничего возвращать, но всё так же работать в контексте конфига. Здесь я воспользуюсь функцией sequenceS из модуля fp-ts/Apply, чтобы преобразовать структуру вида


interface AdHocStruct {  readonly db: App<Database>;  readonly express: App<Express>;}

к типу App<{ readonly db: Database; readonly express: Express }>. Мы якобы достаём из структуры данные, обёрнутые в контекст App, и собираем новый контекст App с похожей структурой, только содержащей уже чистые данные:


import { sequenceS } from 'fp-ts/Apply';const seq = sequenceS(R.Apply);const application: App<void> = pipe(  seq({    db: databaseConnection,    express: expressServer  }),  R.map(    ({ db, express }) => {      console.log([db, express].join('; '));      console.log('app was initialized');      return;    },  ),);

Чтобы запустить Reader<E, A> на выполнение, ему необходимо передать аргумент того типа, который зафиксирован в типопеременной E, и результатом будет значение типа A:


application({  host: 'localhost',  port: 8080,  connectionString: 'mongo://localhost:271017',});

Наконец, объединяя две вышеописанные концепции, мы приходим к последней для данной статьи абстракции ReaderTaskEither.


ReaderTaskEither<R, E, A> задача, выполняющаяся в контексте окружения


Комбинируя Reader и TaskEither, мы получаем следующую абстракцию: ReaderTaskEither<R, E, A> это асинхронные вычисления, которые имеют доступ к некоему неизменному окружению типа R, могут вернуть результат типа A или ошибку типа E. Оказалось, что такая конструкция позволяет описывать подавляющее большинство задач, с которыми в принципе приходится сталкиваться программисту при написании функций. Более того, заполняя типопараметры ReaderTaskEither значениями any и never, можно получить такие абстракции:


// Task никогда не может упасть и может быть запущен в любом окружении:type Task<A> = ReaderTaskEither<any, never, A>;// ReaderTask никогда не падает, но требует для работы окружения типа `R`:type ReaderTask<R, A> = ReaderTaskEither<R, never, A>;// TaskError может упасть с обобщенной ошибкой типа Error:type TaskError<A> = ReaderTaskEither<any, Error, A>;// ReaderTaskError может упасть с ошибкой типа Error и требует для работы // окружение типа `R`:type ReaderTaskError<R, A> = ReaderTaskEither<R, Error, A>;// TaskEither, с которым мы познакомились ранее, может быть представлен как // алиас для ReaderTaskEither, который может быть запущен в любом окружении:type TaskEither<E, A> = ReaderTaskEither<any, E, A>;

Для ReaderTaskEither в соответствующем модуле fp-ts реализовано большое количество конструкторов, деструкторов и комбинаторов. Однако сам по себе ReaderTaskEither не так интересен, как схожая по семантике с ним ZIO-подобная конструкция, которая несёт дополнительный интересный механизм под капотом, называемый свободными монадами.


N.B. Про ReaderTaskEither я достаточно много говорил на камеру в пятом эпизоде видеоподкаста ФП для чайника. Пример, который я там рассматриваю, можно найти здесь.



На этом данную статью я заканчиваю. Абстракция ReaderTaskEither плавно подвела нас к концепции алгебраических эффектов. Но перед тем, как рассмотреть их на примере ZIO-подобной библиотеки Effect-TS, в следующей статье я хочу поговорить о свободных конструкциях на примере свободных и более свободных монад (Free & Freer monads).


Вы можете найти примеры кода из этой статье у меня в Gist на гитхабе.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru