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

    Привет, Хабр! Меню зовут Юрий Богомолов, и вы (возможно) можете меня знать по моей работе над серией #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, B> = URItoKind2<A, B>[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 и т.д., а сразу называть вещи своими именами — функтор, монада, аппликатив? Пишите, буду рад пообщаться в комментариях.

    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 59

      +10

      Если честно, я чувствую себя несколько обманутым. Определение показали, технику — продемонстрировали, а вот того, как всё это на практике работает и как помогает — нет. Мне кажется, было бы полезнее, если бы вместо просто перечисления библиотек, работающих поверх типов fp-ts вы показали, как можно писать код с их использованием и какие преимущества даёт их дизайн.

        +1

        Мне жаль, что я заставил вас так себя почувствовать. Я ставил целью этой статьи как раз рассказать про технику дефункционализации в разрезе HKT, и не более того. На самом деле, каждая из упомянутых библиотек заслуживает своей статьи — скажем, та же parser-ts мне позволила в ~170 строк написать парсер поисковых выражений с булевой логикой, скобками и кавычками. А io-ts позволяет писать poor man's refinement types — и на этом можно очень красиво моделировать предметную область в стиле DDD. Или моя kleisli-ts — это совершенно другой стиль программирования, который заставляет по-иному выворачивать мозги, нежели в обычном императивном программировании. Но всё в рамках одной статьи не охватить.
        Если всё-таки будет интерес к теме, я подумаю, чтобы чередовать теоретический материал с обзорами библиотек. Спасибо за идею — я ее записал.

          +2
          Обманутым себя не почувствовал, но продолжение напрашивается. Идеи интересные, я хоть и далек от TS, но может даже что-то позаимствовать получится.
            0

            Про продолжение поддерживаю! Спасибо автору

            +2

            Было бы интересно почитать про io-ts. Особенно про непростые типы вроде взаимно-рекурсивных определений, объединений и пересечений. Официальная документация не слишком богата на примеры.

              0

              Кстати интересно, насколько масштабируется такой подход? Одно дело программы на сотни строк (тут скорее всего без разницы как писать, дело вкуса). Другое — сотни тысяч, с распределенными командами и долгосрочной поддержкой.

            • НЛО прилетело и опубликовало эту надпись здесь
                +5

                А тут всё понятно, какие сложности?

                  0

                  Из того что мне попадалось, самыми живучими оказывались максимально простые для понимания решения, хорошо интегрированные с языком. Например, всем известно, что промисы это неправильные монады, но ни кто не спешит это исправлять. Для разрешения неопределенностей с nullable значениями, вместо maybe и do notation в язык добавляют простой синтаксисический сахар optional chaining. Собрать обобщенный map, как в хаскеле, это прикольно. Но профит не особо ясен. Отладка даже в простых случаях превращается в квест.

              +2
              Очень хорошое объяснение, теперь стало намного понятнее как устроена вся концепция fp-ts. Примерно это я ожидал увидеть в их доках, но там многие вещи довольно сухо описаны.

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

              Также было бы интересно увидеть какие-нибудь примеры использования, такие как в grossbart.github.io/fp-ts-recipes/#. Помню в самой документации была страница с гайдами по использованию типов Either, Task и подобных, но теперь не могу ее там найти.
                +1

                Спасибо! Джулио хорошо про fp-ts пишет на DEV. Я тоже не до конца понимаю, почему на этой площадке, а не в документации по самой fp-ts, но я не считаю себя в праве указывать человеку, где публиковаться.

                0
                Вопрос только один. «Ты зачем усы сбрил Юрик». Когда продолжение видео по ФП? Где про рекурсию?
                  0

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

                    +1
                    Ох. Только что посмотрел видео. Извиняюсь, не знал. Соболезную. В любом случае, жду.
                +2
                Учитывая техническую продвинутость аудитории Хабра, возможно, нет смысла объяснять на Mappable/Chainable и т.д., а сразу называть вещи своими именами — функтор, монада, аппликатив?

                Во-первых, продвинутость везде разная, и тут тоже. А во-вторых, связь между этими двумя частями ФП — математикой, и кодом, она как раз зачастую ускользает, и многие статьи рассказывают про ФП лишь с одной стороны. Поэтому примерно так как вот тут написано — ну может не идеально, но как раз затрагивает обе стороны.
                  0

                  +1


                  Например, мне очень понравилось что в "Learn Haskell for Great Good" понятие монады вводится в самом конце, когда уже видна потребность в нем. При том, что монады типа List или Maybe там используются почти что с самого начала. Просто не говорится, что это — монады.


                  Для людей, которые вышли из императивного программирования так проще и понятнее, чем начинать с классического "Монада — это такой функтор с дополнительной структурой"

                    0
                    >понятие монады вводится в самом конце
                    Или наоборот :) Людям, которым просто нужно применить это в коде, очень редко поясняют, зачем это все вообще. Ну т.е. такой функтор — и чё? А на этом статья уже закончилась.
                    0
                    Во-первых, продвинутость везде разная, и тут тоже. А во-вторых, связь между этими двумя частями ФП — математикой, и кодом, она как раз зачастую ускользает, и многие статьи рассказывают про ФП лишь с одной стороны. Поэтому примерно так как вот тут написано — ну может не идеально, но как раз затрагивает обе стороны.

                    Функтор, Монада — это же просто название. Ну как если вместо паттерна "Абстрактная фабрика" будут говорить какой-нибудь "Creatable" — много ли поменяется?

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

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

                        Ну берете теорию категорий, там объясняется математически всё. Монады/комонады, сопряжения, конусы и прочая ерунда. Можно увидеть дуальность в discriminated unions и кортежах, можно видеть абстрактную красоту натуральных трансформаций Option -> List и т.п. Нужно ли это для разработки? Определенно нет, это как история развития ЭВМ — дает некоторое представление, почему что-то сделано так, а не иначе, но большого смысла в том чтобы это знать нет. Но может быть интересно. В таком случае берется книга Бартоша и читается, мне она из всех понравилась больше всех. С примерами на хаскелле и плюсах.


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

                        Я ниже привел пример, зачем это нужно. Вы уж определитесь — вам нужно математическое точное определение, но которое хрен поймешь:


                        image


                        А можно как я выше и ниже написал — "на пальцах", но тогда никаких идеальных описаний и точных свойств. Уж извините.

                          0
                          А можно как я выше и ниже написал — «на пальцах», но тогда никаких идеальных описаний и точных свойств. Уж извините.


                          Я только не пойму — вы считаете что это хорошо что-ли? Я поясню — мне бы хотелось видеть в статьях на тему ФП сочетание математики и кода. Мне не нужно точное описание — но хочется понимать, из каких математических свойств той или иной конструкции вытекают ее полезные для кода свойства. Доказательства при этом обычно не требуются вообще.

                          Давайте зайдем с другой стороны — вот скажем ООП, ну или там какие-то другие вещи типа SOLID или там GRASP, это все в общем-то принципы, которые на математике вообще не основаны. Это какие-то конструкции, более или менее абстрактные, кем-то придуманные и потом возможно обобщенные. Если ФП, в отличие от, претендует быть основанной на математике, то эту связь с математикой нужно демонстрировать и показывать, особенно если это пост имеет целью ФП пропагандировать «в массах».

                          Ну и как бы именно таких статей на мой взгляд все еще недостает. Я при этом прекрасно понимаю, что написать такой текст сложно. Мог бы — сам бы писал.
                            0

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


                            А так, судя по вашему описанию вам как раз подойдет книга Бартоша "теоркат для программистов". На хабре были переводы первых глав, но имхо лучше читать в оригинале все подряд. Там эта самая связь математики с программированием и озвучивается. В других местах подобного объяснения не встречал. Да и зачем писать по второму разу то, что один человек уже хорошо описал.

                              0
                              >вам как раз подойдет
                              Мне самому достаточно Бартоша, как раз, хотя за исходники все равно спасибо — как-то они мне не попадались раньше. Но учить на нем начинающих я не готов, хочется что-то попроще.

                              >Да и зачем писать по второму разу
                              Ну это смотря что и как качественно. В конце концов, если речь про примеры кода — то кому-то нужно на скале, а кому-то на TS. Ну и потом, Бартош — это учебник, а не пост на хабре. Это разные жанры слегка. И бывают нужны всякие, не только учебники.
                                0
                                Мне самому достаточно Бартоша, как раз, хотя за исходники все равно спасибо — как-то они мне не попадались раньше. Но учить на нем начинающих я не готов, хочется что-то попроще.

                                Ничего проще нет. И не уверен, что можно сделать проще. Впрочем, контраргументом будет если кто-то такое напишет. Я пока не встречал.


                                Ну это смотря что и как качественно. В конце концов, если речь про примеры кода — то кому-то нужно на скале, а кому-то на TS. Ну и потом, Бартош — это учебник, а не пост на хабре. Это разные жанры слегка. И бывают нужны всякие, не только учебники.

                                Так учебник по сути это просто собранные в одном месте посты из его блога за несколько лет :) Если вам важна форма, а не содержание, то можете из разрозненных блогпостов всё то же самое получить. Но мне кажется, это сложнее воспринимать.

                    +1

                    Очень доступное объяснение HKT. По fp-ts и на английском трудно найти подробные туториалы, так что пожалуйста продолжайте, буду с интересом следить.

                      +3

                      Вау. Наконец то кто-то объяснил что такое Kind-ы незудобробильным языком. Спасибо.


                      Пара вопросов:


                      • Там была пара эпичных тредов на гитхабе про Kind-ы. Что в итоге решили? Их будут внедрять?
                      • Вот это вот каррирование (во всё таки императивном языке) разве не вызывает падение производительности в числодробилках в разы? К примеру вы выше про парсер в 170 строк писали
                      • Почему switch(true) вместо switch(tag)?
                      • Вы применяете это в боевую на серьёзных проектах? Или только как хобби? Просто довольно сложно найти разработчиков, который бы умели в метапрограммирование уровня полиморфизмов высокого порядка. Падает bus factor и всё такое
                      • Где типы круче, в Scala2 или в TS? :) Судя по отзывам моих scala-коллег ответ неоднозначный
                        +2

                        Ещё 1 вопрос:


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

                        Речь только про линтер-правила или вообще тотальный запрет? Я сколько не пытаюсь, как не стараюсь, всё равно в сложных вещах скатываюсь в any. Не хватает либо мозгов, либо возможностей языка, либо ни того, ни другого. Взял за правило — главное чтобы наружний тип был максимально мощным/строгим/полезным, не так важно если из-за этого приходится внутри реализации делать касты, any и прочую бесовщину. Внутренний код можно плотно протестировать, не беда. А вот сильный внешний тип будет большим подспорьем. Во имя бога DRY.


                        У вас так же получается? Поспрашивал scala-коллег — говорят что у них примерно такая же петрушка во всех сложных библиотеках. Каст на касте.

                          +1

                          На тех проектах, которые я начинаю с нуля, всегда strict = true и в линтере any является ошибкой. Перед компиляцией запускается линтер, поэтому код с any в принципе не скомпилируется без отключения линтера, что будет видно на ревью. Это идеалистичный подход, но практически во всех случаях удается написать корректный тип. У тайпскрипта есть много штучек в рукаве — те же mapped types, conditional types с волшебным infer или банальная перегрузка функций позволяют решить почти все задачи. «Почти» — потому что за всю практику я ровно один раз не смог выразить в типах то, что выполняла функция, потому что для этого нужна была зависимая типизация.


                          Взял за правило — главное чтобы наружний тип был максимально мощным/строгим/полезным, не так важно если из-за этого приходится внутри реализации делать касты, any и прочую бесовщину

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

                            0
                            У тайпскрипта есть много штучек в рукаве — те же mapped types, conditional types с волшебным infer или банальная перегрузка функций позволяют решить почти все задачи

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

                              0
                              В таких случаях работает перегрузка.
                                +1

                                Ну как работает. Вы просто any поставили :)

                                  –1
                                  Если вы внимательно посмотрите на то, какие типы выводятся — увидите, что таки нет.
                                    +1


                                    Не очень понимаю о чём вы, но посмотрите вот на эту картинку. У вас завёлся any. И нет, поменять его на unkown нельзя. И да вы можете теперь из этого метода вернуть всё что угодно и TS не ругнётся.

                                      –1
                                      Не очень понимаю о чём вы

                                      О том, что ваша попытка не работает вообще. Моя — наполовину (снаружи функции) работает.
                                        +1

                                        Теперь ещё больше вас не понимаю. Моя попытка работает снаружи. Посмотрите на :30 и :31 строку. Типы вывелись корректные, согласно их аргументу.


                                        Аааа. Я кажется понял вас. Вы про то, что TS подкрашивает код внутри метода красным? Хех, ну это я просто не сделал так:


                                        return resA as TResult<T>

                                        что по сути мало чем лучше, чем ваш any (хотя и лучше, т.к. даёт меньше свобод). Я целенаправленно оставил ошибки, чтобы было понятно в чём загвоздка.


                                        Т.е. моё решение очень даже работает. Но, увы, тоже при помощи насильственного приведения типов.


                                        Моё решение:


                                        • правильные типы снаружи
                                        • внутри позволяет вернуть любой из TResult, в то время как надо TResult<T>
                                        • при наведении указывает не TSourceA а TSourceA & T

                                        Ваше решение:


                                        • правильные типы снаружи
                                        • внутри позволяет вернуть всё что угодно
                                        • при наведении показывает TSourceA как и должен (без & T)

                                        Идеальное решение (умеет ли так TS?)


                                        • правильные типы снаружи
                                        • правильные типы внутри
                                0
                                Не уверен, что сделал все прям уж отлично, но примерно вот такое решение
                                  0
                                  const fn: overloadfn = (arg: TSource): TResult<TSource> => {
                                    if (guardA(arg)) {
                                      arg; // TSourceA
                                      //const resA: TResultA = 'Result-A';
                                      //return resA; // ok - TResultA
                                  
                                      const resB: TResultB = 'Result-B';
                                      return resB; // not ok - TResultB
                                    }
                                  
                                    if (guardB(arg)) {
                                      arg; // TSourceB
                                      const resB: TResultB = 'Result-B';
                                      return resB; // ok - TResultB
                                    }
                                  
                                    return arg; // never
                                  }

                                  Как видите решение не работает :) Я вернул неправильный тип и не получил ошибок.

                            +3

                            Спасибо :) По порядку:


                            • Полноценных родов в TS мы можем не дождаться. Для команды разработки компилятора главное — максимальная совместимость с JS, а не фичи системы типов.
                            • Вызывает, разумеется. Более того, при портировании на TS хаскельных приёмов следует очень хорошо понимать ленивость в хаскеле и транслировать код с учетом этой специфики. В числодробилках с большой вероятностью простой императивный (а то и процедурный) код будет более производительным — всё-таки ВМ JS не настолько умная, как тот же GHC. Но в реальном боевом коде, который работает с базой, очередью, файловым I/O, бутылочное горлышко производительности находится отнюдь не в каррированных функциях. Так что я обычно принимаю небольшую просадку числодробильной производительности как неизбежный tradeoff. Разумеется, всё это доношу до ЛПРов проекта, чтобы не возникло сюрпризов в дальнейшем.
                            • Привычка — switch (true) с последующим вынесением условий в case позволяет имитировать код с pattern-matching'ом. В том примере, о котором вы говорите, и правда можно было написать switch (as.tag).
                            • Оба примера, которые я привел в комментарии выше — про парсер и про io-ts — взяты из моих боевых проектов. В бою же я использовал схемы рекурсии (на митапе в Тинькофф про них рассказывал), в бою же есть код с оптикой на monocle-ts. Стрелки Клейсли я в бой пока не осмеливаюсь тащить именно из-за высокого bus factor.
                              Ну и про сложность поиска разработчиков — в JS/TS это проблема, в то же скале или хаскеле обыденность, для типотеоретиков, которые с агдой или коком работают — детский лепет. Всё относительно. На практике получается проще плавно обучать разработчиков на месте, нежели искать готового человека на рынке.
                            • В Scala 2 не хватает нормальных типов-сумм, приходится через sealed trait с case-классами писать. Синтаксически более шумно, но задачу свою выполняет. При этом в скале есть нормальные HKT — да что там говорить, благодаря path dependent types в скале даже HoTT реализовали! Так что TS здесь явно в позиции отстающего.
                          • НЛО прилетело и опубликовало эту надпись здесь
                              +6

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

                                +2

                                Зачем мне функциональщина и тайпскрипт чтобы показать и скрыть форму регистрации, провалидировать регуляркой пару инпутов, отправить аякс запросы. Насколько удобно функциональное программирование в разработке UI? Что она дает конкретно а этом случае? Улучшается производительность, читабельность кода, тестируемость? Нельзя просто так брать и вводить что-то не оценив все плюсы и минусы

                                  +3

                                  Спасибо за вопросы. Касательно «зачем тайпскрипт» — всё очень просто: типы это лучшая документация. В грамотно спроектированной библиотеке вам не нужно выходить из IDE, чтобы посмотреть, какой заковыристой формы нужно собрать объект с опциями, чтобы вызвать нужную вам функцию. В еще более правильно спроектированной экосистеме компилятор будет бить вас по рукам за попытки нарушить флоу бизнес-логики.
                                  Касательно «зачем функциональщина для лендингов» — немного ударюсь в демагогию и отвечу вопросом на вопрос: а зачем тогда вообще писать код, если на NoCode-подходе (Wix/Tilda/etc) можно лендинги собирать на поток? Если же серьезно, то ФП и правда дает лучшую тестируемость (особенно при использовании фри-монад или tagless final-кодировки), лучшую модульность и композируемость кода, в целом случается меньше ошибок, становится проще держать в голове контекст, с которым работаешь. И если при сборке лендинга для ООО «Рога и Копыта», торгующей автошинами, это всё не нужно, то при сборке лендинга для какого-нибудь банковского проекта, где нужна сложная валидация данных и многоступенчатое заполнение формы, использование практик ФП сильно упрощает жизнь.
                                  Ну и я на 100% соглашусь с мыслью о том, что нужно оценивать плюсы и минусы — серебряных пуль нет, и нужно это всегда держать в голове при старте проекта.

                              +1
                              Было бы не плохо в начале статьи сообщить для на кого эта статья рассчитана. На профессоров кэмбриджа или это полезно знать и обычной пехоте. Имеет ли она интерес для людей занимающихся теорией языков программирования или это может пригодится в разработке например SPA на typescript.

                              Учитывая техническую продвинутость аудитории Хабра, возможно, нет смысла объяснять на Mappable/Chainable и т.д., а сразу называть вещи своими именами — функтор, монада, аппликатив?

                              После таких фраз, мало кто решится написать статью для новичков. Раз нет смысла объяснять Mappable/Chainable, то все кто не понимает «функтор, монада, аппликатив» будут испытывать чувство своей неполноценности.

                              Техническую продвинутость бывает разная. К примеру я начал изучать TS две недели назад, и то, из-за перехода на Vue 3. И могу сообщить, что смысл объяснять есть. Более того, удобно читать даже сложный материал, когда автор учитывает разный уровень аудитории.

                              И раз уж начал писать, можно вопрос не по теме:
                              Для разработки стандартных задач фронтэнда я могу понять необходимость типов/интерфейсов, чтобы строго типизировать простые типы, объекты и функции. Избегаем ошибок на этапе компиляции, польза очевидна. То «функтор, монада, аппликатив» кажутся некоторым перебором. Да очень удобно пользоваться .map или .reduce и т.д. — такое ФП пригождается в реальной жизни, но mappable — хотелось бы понять — зачем? Иногда складывается ощущение, что все эти монады, функциональные оптики и прочие штучки созданы для чесания ЧСВ, а по факту все можно сделать проще, причем с лучшей читабельностью.

                                +1

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


                                «функтор, монада, аппликатив» кажутся некоторым перебором. Да очень удобно пользоваться .map или .reduce и т.д. — такое ФП пригождается в реальной жизни, но mappable — хотелось бы понять — зачем?

                                Ответ может показаться поначалу неочевидным, но я попробую объяснить свою мысль детально. Чем выше мы восходим по слоям абстракции, тем более гибким, композируемым и модульным получается код, а как следствие — его становится проще рефакторить и модифицировать в дальнейшем. Использование map/reduce/filter или комбинаторов из ramda это только первый шаг по лесенке абстракции — вынесение каких-то общих императивных паттернов во вспомогательные функции. Дальше мы начинаем думать, как обобщать паттерны доступа к данным в сложных структурах, и приходим к понятию функциональной оптики (те же lens/view/set/over из рамды, или monocle-ts как более зрелый подход). Потом мы начинаем думать, как бы обобщить другие аспекты программирования — скажем, абстрагироваться от исключений, от дуальности синхронности/асинхронности, от работы в контексте какой-либо конфигурации. Этот полёт мысли даст рождение как раз тем концепциям, которые вызвали у вас вопрос — функтору (абстракция над операцией изменения данных в контексте), монаде (абстракция над вычислительным контекстом, в котором вычисления зависят от результата предыдущих), аппликативу (абстракция над независимыми вычислениями), а также ряду управляющих и контейнерных структур — Either, Task, IO, Reader, и т.п. Рассуждения о природе и видах рекурсии дало рождение понятию схем рекурсии (recursion schemes), это те самые пугающие слова вида «хистоморфизм», «препроморфизм» и так далее. В конце мы приходим к необходимости писать декларативный код, причем желательно так, чтобы у программиста не было выразительных средств для допущения ошибки — и приходим к eDSL, моноидальным категориям, стрелкам Клейсли и другим штучкам для чесания ЧСВ :)


                                По сути, всё ФП — это восхождение по ступеням абстракции. И тут вы очень правильно заметили, что «всё можно сделать проще» — в каждом конкретном проекте необходимо принять решение, когда следует остановить это восхождение. Если в простых SPA вряд ли понадобится заходить дальше использования оптики и простых контейнеров типа RemoteData, Either и Maybe, то в наукоёмких или просто сложных задачах вроде массово-параллельных и распределенных вычислений может понадобиться сделать еще пару шагов вверх, чтобы получить надёжное и корректно работающее решение.

                                  +3
                                  функтор — абстракция над операцией изменения данных в контексте;
                                  монада — абстракция над вычислительным контекстом, в котором вычисления зависят от результата предыдущих
                                  аппликатив — абстракция над независимыми вычислениями

                                  Гегель отдыхает. Есть над чем поразмыслить.

                                  Но в дидактических целях интересно бы идти от практических примеров, причем с самого начала цепочки абстракций.

                                  В жизненной практике ясно зачем forEach, map, filter, reduce даже по самым базовым примерам. Как вы и объяснили — «вынесение каких-то общих императивных паттернов во вспомогательные функции». Хорошо излагаете.

                                  А вот дальше уже интересное. «Обобщать паттерны доступа к данным в сложных структурах» — звучит вроде понятно, изложено опять же красиво. Это было бы интересно увидеть на примере.

                                    0
                                    А вот дальше уже интересное. «Обобщать паттерны доступа к данным в сложных структурах» — звучит вроде понятно, изложено опять же красиво. Это было бы интересно увидеть на примере.

                                    Тут как раз проще всего — примеров и мотивации для использования оптики много. Я записывал видео по этой теме: https://www.youtube.com/watch?v=FA9uDWXPHD0

                                      +1
                                      Гегель отдыхает. Есть над чем поразмыслить.

                                      Оно проще чем звучит.


                                      А вот дальше уже интересное. «Обобщать паттерны доступа к данным в сложных структурах» — звучит вроде понятно, изложено опять же красиво. Это было бы интересно увидеть на примере.

                                      Например, можно абстрагироваться от того, передается ли ошибка исключением или в стиле го (nil, err). И уже на месте решать, хотим ли мы эксепшн или вот такую штуку. При этом саму функцию можно написать абстрагированно от того, как ошибки делаются/прокидыаются. Если что, такой тип есть, называется MonadError.




                                      Другой пример, можно писать код, абстрагированный от асинхронности. Например, у нас есть интерфейс, и у него два наследника: один синхронный а другой нет


                                      interface ISettingsProvidet<M> {
                                         M<MySettings> GetSettings();
                                      }
                                      
                                      class Id<T> {
                                         public T Value {get;init};
                                      }
                                      
                                      class AsyncProvider : ISettingsProvider<Task> { 
                                         Task<MySettings> GetSettings() => ...
                                      }
                                      
                                      class SyncProvider : ISettingsProvider<Id> { 
                                         Id<MySettings> GetSettings() => ...;
                                      }

                                      И теперь можем написать абстрагированный код:


                                      M<int> Foo<M>(ISettingsProvider<M> provider) {
                                         var settigns = provider.GetSettings();
                                         return settings.Map(x => x.Something.BusinessCount);
                                      }

                                      Теперь вызывающий код сможет выбрать, хочет ли он асинхронно или синхронно его выполнить.




                                      А вообще это как с паттернами: просто читать про них толку нет — нет понимания, зачем это все и кому нужно. Нужно руками ковырять, тогда понимание сразу приходит.

                                  0
                                  Многие смеются над JavaScript, но когда я слез с Java/Scala стека и окунулся в чудесный мир JavaScript, я был счастлив.

                                  Не нужно больше было писать десяток классов на Spring, когда на NodeJS ты можешь написать все в одной-двух функциях. Не нужно больше было исполнять акробатику с типами для обработки обычного http-запроса на Scala. JavaScript позволяет писать просто, лаконично и понятно. TypeScritpt позволяет добавить чуточку типизации, где это необходимо без излишнего фанатизма.

                                  А что касается абстрактных монад — кроме тех, что прочно вошли в большинство языков программирования (как то списки с map/filter/reduce и промисы) — в клепании формочек совершенно бесполезны, а скорее вредны.

                                  Вообще, JavaScript программисты редко понимают, в каком прекрасном мире, они живут. То, что в JavaScript экосистеме обыденность, в других языках работает гораздо хуже. Например, npm. Это самый простой и удобный пакетный менеджер зависимостей, полный стабильных и хорошо работающих библиотек на любой случай с хорошо оформленной документацией. В других языках пакетный менеджер гораздо более убогий, а назначение библиотек, как они работают и их API зачастую невозможно понять по README.md в гитхабе. В Scala, например, совершенно обычное явление, когда библиотека написана наполовину и брошена, когда автору надоело. Ну а для авторов библиотек какой-нибудь maven — это просто боль по сравнению с npm.

                                  А таких удобных штук как babel и typescript, которые позволяют подключать и отключать синтаксис и типизацию модулями — вообще ни у кого наверное нет.
                                    +1
                                    Например, npm. Это самый простой и удобный пакетный менеджер зависимостей, полный стабильных и хорошо работающих библиотек на любой случай с хорошо оформленной документацией.

                                    С хорошо работающими майнерами, ворующих инфу у пользователей и просто ломающихся, когда кто-то решил удалить пакет для дополнения строки пробелами слева.


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

                                    Вы так говорите, как будто README.md достаточно для того, чтобы понять, как использовать библиотеку на JS. И да, в качестве примера пакетных менеджеров советую посмотреть на Cargo и Stack.


                                    А таких удобных штук как babel и typescript, которые позволяют подключать и отключать синтаксис и типизацию модулями — вообще ни у кого наверное нет.

                                    А знаете, почему? Потому что за пределами фронтенда, в котором код интерпретируется у клиента, это нафиг никому не нужно.

                                    • НЛО прилетело и опубликовало эту надпись здесь
                                    0

                                    Остаётся один вопрос — почему тип Kind называется Kind, и какое отношение он имеет к родам?

                                      0

                                      Kind и род это одно и то же — тип конструктора типов. Еще kind переводят как «сорт» и «вид», мне почему-то ближе перевод «род». Могу использовать кальку «кайнд», но это уже каким-то летмиспикфроммайхартом отдает.

                                        0

                                        Ну да, это тип конструктора типов (хотя слово "конструктор" мне тут кажется немножко лишним).


                                        Остаётся вопрос, какое отношение тип Kind из поста имеет к типу конструктора типов.

                                          0

                                          Тип Kind это служебный, если хотите, оператор, который говорит: «Связь URI "F" и какого-то типа А означает F», или, другими словами, что тип F — это род (тип конструктора типов). Если URI F == 'Array', то сам F — Array, если URI F == 'Promise', то F — Promise, и так далее. Не конкретные «массив чисел» или там «промис списка юзеров», а именно что абстрактные «массив» и «промис», которые являются конструкторами типов, а их тип — это как раз род.

                                            0

                                            Ну нет. Во-первых, F — это не род никоим образом, это всего лишь тип рода * -> *. Во-вторых, я всё ещё не понимаю почему служебный оператор называется Kind.

                                    0

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

                                      –2

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

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое