Раздел 25 · Effect-TS

HKT и TypeLambda: один код над Effect, Stream и Result

senior~90 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

HKT и TypeLambda: один код над Effect, Stream и Result

Сцена · одна функция на два мира

Ты пишешь CLI. Внутри такая операция: запустить команду, но если бинарника нет в PATH, поискать его в кеше и запустить оттуда, а если и там нет, вернуть понятную ошибку. Логику хочется написать один раз.

Проблема в том, что та же операция нужна в двух местах. В одном месте команда это Effect<Output, PlatformError, ChildProcessSpawner>, и ты хочешь вернуть Effect. В другом это Stream<string, PlatformError, ChildProcessSpawner>, и вернуть хочется Stream. Разные типы, одна логика.

Ты пишешь функцию, которая берёт реализацию нужных операций параметром и отдаёт готовую обёртку:

const withEffectFallback = withFallback(EffectUnified);
const withStreamFallback = withFallback(StreamUnified);

withFallback тут одна. EffectUnified и StreamUnified это две маленькие таблицы, которые говорят, как для конкретного типа выразить flatMap, fail, catchIf и fromEffect. Остальное, вся логика поиска в кеше, лежит внутри withFallback и написана один раз.

Как вообще написать такую функцию, если Effect<A, E, R> и Stream<A, E, R> это два независимых типа? Язык, в котором это делают нативно, называется Haskell, и там параметр f в классе Functor означает конструктор типа, а не тип. TypeScript так не умеет, и его приходится обманывать.

Обманов два. Первый ты видел в уроке про typeclasses, это URI-trick из fp-ts. Второй, на котором стоит сама библиотека Effect, это TypeLambda и Kind. Он честнее, переносит больше проверок на компилятор и не требует глобальной таблицы. В этом уроке разбираем его целиком и заканчиваем ровно тем кодом из сцены.

Раздел 1 · почему F<A> не пишется

Начнём с того, что не компилируется. Хочется написать интерфейс монады так:

interface Broken<F> {
  flatMap: <A, B>(self: F<A>, f: (a: A) => F<B>) => F<B>;
}

TypeScript отвечает ошибкой:

error TS2315: Type 'F' is not generic.

Причина в устройстве типов. Kind у F это Type, готовый тип. А Array, Promise, Option, Effect, Stream это не типы, это конструкторы типов, их kind это Type -> Type (или стрелка подлиннее, если параметров несколько). Параметр дженерика в TypeScript пробегает по типам, а не по конструкторам.

Отсюда ошибка: F уже тип, приписать к нему <A> нечего.

В Haskell такого барьера нет. Класс Functor объявляется так:

class Functor f where
  fmap :: (a -> b) -> f a -> f b

Здесь f в голове класса это переменная над конструктором типов, язык понимает, что f a надо развернуть в конкретный тип при реализации. HKT это и есть эта возможность, и TypeScript её не даёт из коробки.

Раздел 2 · два способа обмануть язык

Оба способа делают одно и то же: превращают конструктор типа в нечто, что можно передать параметром.

Способ первый · URI-trick

Ты сопоставляешь каждому контейнеру строку-URI и держишь таблицу URItoKind<A>:

interface URItoKind<A> {
  readonly Array: A[];
  readonly Option: Option<A>;
}

type URI = keyof URItoKind<unknown>;
type Kind<F extends URI, A> = URItoKind<A>[F];

Теперь Kind<'Array', string> разворачивается в string[]. Таблица глобальная, F это просто строка, и через module augmentation в неё добавляют новые контейнеры. Полный разбор и продолжение на higher-kinded data лежат в 02-cs · 07 и 02-cs · 15, сюда не повторяю.

У этого способа есть предел. Таблица знает про контейнер ровно один тип-параметр (A), а Effect<A, E, R> их три, и они разной природы: A это результат, E ошибка, R требования. Запихнуть три параметра в один слот A не выйдет, а делать три таблицы значит потерять автоматические объединения по каналам, которыми Effect живёт.

Способ второй · TypeLambda

Effect решает это иначе. Каждый контейнер объявляет интерфейс из четырёх фантомных слотов, и отдельный тип Kind применяет к нему аргументы. Слотов ровно четыре, потому что этого хватает всему зоопарку: один входной слот и три выходных.

Вот базовый интерфейс, как он записан в effect/HKT:

export interface TypeLambda {
  readonly In: unknown;
  readonly Out2: unknown;
  readonly Out1: unknown;
  readonly Target: unknown;
}

Имена слотов описывают не роль, а вариантность. In контравариантный, это вход; Out2 и Out1 ковариантные, это выходы; Target инвариантный, это главный результат. Каждый контейнер закрепляет за слотами свои параметры одной строкой type.

// Option<A>
interface OptionTypeLambda extends TypeLambda {
  readonly type: Option<this['Target']>;
}

// Result<A, E>
interface ResultTypeLambda extends TypeLambda {
  readonly type: Result<this['Target'], this['Out1']>;
}

// Effect<A, E, R>
interface EffectTypeLambda extends TypeLambda {
  readonly type: Effect<this['Target'], this['Out1'], this['Out2']>;
}

// Stream<A, E, R>
interface StreamTypeLambda extends TypeLambda {
  readonly type: Stream<this['Target'], this['Out1'], this['Out2']>;
}

// функция (a: A) => B
interface FunctionTypeLambda extends TypeLambda {
  readonly type: (a: this['In']) => this['Target'];
}

this['Target'] это и есть та самая переменная над конструктором, которой не хватало. Только записана она не напрямую, а через слот интерфейса, и потому язык её принимает.

Заметь две вещи. In реально используется только у функции, там он и означает вход. У Effect, Stream и Result входного канала нет, поэтому их лямбды на In вообще не смотрят. Out1 везде несёт ошибку E, а Out2 у трёхпараметричных несёт окружение R.

Раздел 3 · как Kind разрешает слоты

Слоты сами по себе ничего не делают, их надо применить. За это отвечает Kind. Вот его настоящая реализация из effect/HKT:

export type Kind<F extends TypeLambda, In, Out2, Out1, Target> = F extends {
  readonly type: unknown;
} ? (F & {
    readonly In: In;
    readonly Out2: Out2;
    readonly Out1: Out1;
    readonly Target: Target;
  })['type']
  : {
      readonly F: F;
      readonly In: Types.Contravariant<In>;
      readonly Out2: Types.Covariant<Out2>;
      readonly Out1: Types.Covariant<Out1>;
      readonly Target: Types.Invariant<Target>;
    };

Читается по веткам условного типа.

Если у F есть поле type, то есть F это настоящая лямбда, берётся пересечение F с объектом, где четырём слотам присвоены конкретные аргументы. Внутри этого пересечения this['Target'] и прочие подстановки уже смотрят на присвоенные значения, а ['type'] вытаскивает результат. Так Kind<ResultTypeLambda, never, never, string, number> разворачивается в Result<number, string>.

Вторая ветка срабатывает, когда у F поля type нет, то есть передали голый TypeLambda. Она ничего не считает, а помечает слоты как контравариантный, ковариантные и инвариантный, чтобы компилятор проверил вариантность по месту использования. Для нас важно только первое: пока слоты это просто фантомы на типах.

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

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

export type Kind3<F extends TypeLambda, A, E, R> = Kind<F, never, R, E, A>;

Слоты идут в порядке In, Out2, Out1, Target, то есть never на вход, R в Out2, E в Out1, A в Target. Подставь каждую лямбду и увидишь знакомые типы:

Kind3<OptionTypeLambda, A, E, R>      -> Option<A>
Kind3<ResultTypeLambda, A, E, R>      -> Result<A, E>
Kind3<EffectTypeLambda, A, E, R>      -> Effect<A, E, R>
Kind3<StreamTypeLambda, A, E, R>      -> Stream<A, E, R>
Kind3<FunctionTypeLambda, A, E, R>    -> (a: never) => A

Kind3 это не абстракция ради абстракции, а способ сказать “какое-то из этих четырёх” одной записью. Всё дальнейшее строится на нём.

Раздел 4 · собираем Monad<F> и composeK

Теперь можно написать тот самый интерфейс монады, который в начале не компилировался. Только вместо F<A> пишем Kind:

import { Effect, HKT, Option, Result } from 'effect';

interface Monad<F extends HKT.TypeLambda> {
  flatMap: <I1, O2a, O1a, A, I2, O2b, O1b, B>(
    self: HKT.Kind<F, I1, O2a, O1a, A>,
    that: (a: A) => HKT.Kind<F, I2, O2b, O1b, B>,
  ) => HKT.Kind<F, I1 & I2, O2a | O2b, O1a | O1b, B>;
}

Обрати внимание на арифметику по слотам в возвращаемом типе. Слот In контравариантный, поэтому он объединяется через &. Слоты Out2 и Out1 ковариантные, поэтому объединяются через |. Это ровно то, что делает Effect.flatMap, когда складывает ошибки E | E2 и окружения R | R2 в один тип. Тут это записано один раз для всех контейнеров.

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

const resultMonad: Monad<Result.ResultTypeLambda> = { flatMap: Result.flatMap };
const optionMonad: Monad<Option.OptionTypeLambda> = { flatMap: Option.flatMap };
const effectMonad: Monad<Effect.EffectTypeLambda> = { flatMap: Effect.flatMap };

Компилятор проверил каждую: Kind для Result разворачивается в Result, для Option в Option, для Effect в Effect, и настоящая сигнатура flatMap совпала. Никаких as, вся проверка на месте.

Дальше сама фабрика. Она берёт инстанс и возвращает функцию, которая склеивает два шага в один, сохраняя эффект:

const composeK =
  <F extends HKT.TypeLambda>(monad: Monad<F>) =>
  <I1, O2a, O1a, A, I2, O2b, O1b, B, I3, O2c, O1c, C>(
    f: (a: A) => HKT.Kind<F, I1, O2a, O1a, B>,
    g: (b: B) => HKT.Kind<F, I2, O2b, O1b, C>,
  ) =>
  (a: A): HKT.Kind<F, I1 & I2, O2a | O2b, O1a | O1b, C> =>
    monad.flatMap(f(a), g);

Смотри, что произошло. composeK знает про F только то, что оно TypeLambda и что в monad есть flatMap. Всё остальное это арифметика по слотам. Одна фабрика, три мира:

const result = composeK(resultMonad)(
  (n: number) => Result.succeed(n.toString()),
  (s: string) => Result.succeed([s]),
)(2);

const option = composeK(optionMonad)(
  (n: number) => Option.some(n.toString()),
  (s: string) => Option.some([s]),
)(2);

const effect = composeK(effectMonad)(
  (n: number) => Effect.succeed(n.toString()),
  (s: string) => Effect.succeed([s]),
)(2);

Печатаем:

{ _id: 'Result', _tag: 'Success', value: [ '2' ] } { _id: 'Option', _tag: 'Some', value: [ '2' ] } [ '2' ]

Первое это Result<string[]> через Result.flatMap, второе Option<string[]> через Option.flatMap, третье это Effect<string[]> через Effect.flatMap, распакованный в массив. Три разные машины, одна строка кода на всех.

Раздел 5 · практика · UnifiedTypeClass вместо пакета с type classes

В четвёртой версии Effect отдельного пакета с type классами (@effect/typeclass) из-за которого многие и приходили в SQL-стиль, больше нет. Это не проблема, а смена инструмента: type классы пишут руками поверх effect/HKT, потому что сам HKT остался в ядре. Ты уже видел, как это делается, в composeK. Разница в том, что вместо одной операции flatMap ты набираешь их пакетом, и получаешь функцию, полиморфную по всей пачке.

Начнём с того, что нужно из реальной задачи поиска бинарника. Пять операций: flatMap, fail, catchIf, tapError, fromEffect. Каждая это отдельный интерфейс, параметризованный конструктором F, и все они наследуют TypeClass<F> из effect/HKT. Базовый TypeClass несёт специальный бренд-символ URI, который связывает реализацию класса с её лямбдой:

export interface TypeClass<F extends TypeLambda> {
  readonly [URI]?: F;
}

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

import * as Effect from 'effect/Effect';
import type { Kind, TypeClass, TypeLambda } from 'effect/HKT';
import * as Stream from 'effect/Stream';

export type Kind3<F extends TypeLambda, A, E, R> = Kind<F, never, R, E, A>;

export interface Fail<F extends TypeLambda> extends TypeClass<F> {
  fail<A, E, R>(error: E): Kind3<F, A, E, R>;
}

export interface FlatMap<F extends TypeLambda> extends TypeClass<F> {
  flatMap<A, E, R, B, E2, R2>(
    self: Kind3<F, A, E, R>,
    f: (a: A) => Kind3<F, B, E2, R2>,
  ): Kind3<F, B, E | E2, R | R2>;
}

export interface FromEffect<F extends TypeLambda> extends TypeClass<F> {
  fromEffect<A, E, R>(self: Effect.Effect<A, E, R>): Kind3<F, A, E, R>;
}

export interface CatchIf<F extends TypeLambda> extends TypeClass<F> {
  catchIf<A, E, R, A2, E2, R2>(
    self: Kind3<F, A, E, R>,
    predicate: (e: E) => boolean,
    f: (e: E) => Kind3<F, A2, E2, R2>,
  ): Kind3<F, A | A2, E | E2, R | R2>;
}

export interface Unified<F extends TypeLambda>
  extends FlatMap<F>,
    Fail<F>,
    FromEffect<F>,
    CatchIf<F> {}

FromEffect это мост из мира Effect в мир F. Для самого Effect он вырожденный, тождественный, а для Stream это Stream.fromEffect, который поднимает эффект в поток из одного события. Именно такие мосты делают функцию по-настоящему полиморфной: логика написана в F, а собираются и проверяются куски через Effect и адаптируются на входе.

Теперь два инстанса. Это таблицы, где каждой операции сопоставлена настоящая функция библиотеки:

export const EffectUnified: Unified<Effect.EffectTypeLambda> = {
  flatMap: Effect.flatMap,
  fail: Effect.fail,
  fromEffect: (x) => x,
  catchIf: Effect.catchIf,
};

export const StreamUnified: Unified<Stream.StreamTypeLambda> = {
  flatMap: Stream.flatMap,
  fail: Stream.fail,
  fromEffect: Stream.fromEffect,
  catchIf: Stream.catchIf,
};

И вот она, функция из сцены. Читается как обычный код, а работает над любым контейнером, который прошёл проверку выше:

class NotFoundError extends Error {
  readonly _tag = 'NotFoundError';
}

export const withFallback =
  <F extends TypeLambda>(F: Unified<F>) =>
  <A, E extends Error, R>(run: () => Kind3<F, A, E, R>, fallback: () => Kind3<F, A, E, R>) =>
    F.catchIf(
      run(),
      (e: E) => e instanceof NotFoundError,
      () => fallback(),
    );

Осталось собрать два конкретных варианта и убедиться, что оба живые:

const withEffectFallback = withFallback(EffectUnified);
const withStreamFallback = withFallback(StreamUnified);

const eff = withEffectFallback(
  () => Effect.fail(new NotFoundError('git')),
  () => Effect.succeed(42),
);

const str = withStreamFallback(
  () => Stream.fail(new NotFoundError('git')),
  () => Stream.make(42),
);

console.log(Effect.runSyncExit(eff)); // Exit.Success(42)
console.log(Effect.runSync(Stream.runCollect(str))); // [ 42 ]

Первый запуск даёт { _id: 'Exit', _tag: 'Success', value: 42 }: Effect упал с NotFoundError, catchIf поймал по предикату и подменил на успех с 42. Второй печатает [ 42 ]: тот же путь прошёл Stream, ошибка из Stream.fail поймана тем же предикатом, а Stream.make дал поток из одного события. Одна withFallback, две абсолютно разные машины исполнения.

В настоящем коде catchIf и tapError обычно объявляют с двумя перегрузками, чтобы работал и F.catchIf(self, pred, f), и data-last pipe(self, F.catchIf(pred, f)). Форма сигнатуры от этого не меняется, добавляется только второй порядок аргументов.

Раздел 6 · так это устроено внутри самой библиотеки

composeK и Unified это не учебная игрушка. Effect использует ровно ту же схему у себя, и самое наглядное место это internal/doNotation.ts. Там есть функции bind, bindTo, let_, и каждая принимает map и flatMap как параметры с типом-интерфейсом над TypeLambda:

export const bind = <F extends TypeLambda>(
  map: Map<F>,
  flatMap: FlatMap<F>,
): { /* data-first и data-last перегрузки */ } => /* ... */;

Дальше gen для конкретных контейнеров собирается одним вызовом:

// Result.ts
export const gen: Gen.Gen<ResultTypeLambda> = /* ... */;

const { bind, let_, bindTo } = doNotation.bind<ResultTypeLambda>(map, flatMap);

То же самое делают Option.gen, Effect.gen и Stream.gen. Поэтому yield* внутри Result.gen, Effect.gen и Stream.gen устроен одинаково: генератор, распаковка значения, короткое замыкание на ошибке. Разные машины, один раз написанная обвязка.

Это и есть ответ на вопрос “зачем мне это, если я просто хочу вызвать Effect.flatMap”. Не зачем, пока у тебя один контейнер. Нужно, когда рядом живут несколько, и обвязку хочется написать один раз.

Раздел 7 · HKT в прикладном коде

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

Кортеж, который забыл, откуда пришёл

У тебя есть объединение, и до запуска неизвестно, какой член придёт:

type Channel = 'email' | 'sms' | 'push';

declare const channel: Channel;

type Pair<T> = readonly [T, T];

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

type Expected = Pair<'email'> | Pair<'sms'> | Pair<'push'>;

Собираешь пару в лоб и получаешь другое:

const atOnce = [channel, channel] as const;
//    ^ readonly [Channel, Channel]

// @ts-expect-error слоты больше не обязаны совпадать
const atOnceIsExpected: Expected = atOnce;

Оба слота взяты из одной переменной, но тип кортежа помнит каждый слот отдельно. readonly [Channel, Channel] пускает и ['email', 'sms']: связь между слотами потеряна. Вышел кортеж из объединений, а нужно объединение кортежей.

Обобщённая функция не спасает. <T extends Channel>(c: T): Pair<T> на вызове с channel выводит T как всё объединение и возвращает тот же Pair<Channel>.

В настоящем коде на месте Pair стоят два больших значения, и тип каждого зависит от члена. Так устроен переход между фрагментами воспроизведения в демо next-midi-demo из репозитория effect-garden: оба элемента перехода строятся из одних таймингов (начальный, средний или последний квант), и элементы от разных квантов смешивать нельзя. Pair<T> это скелет той же задачи.

Ручная починка

const byBranching: Expected =
  channel === 'email'
    ? ([channel, channel] as const)
    : channel === 'sms'
      ? ([channel, channel] as const)
      : ([channel, channel] as const);

Три одинаковые ветки, и каждая нужна только затем, чтобы сужение назвало компилятору член. Новый член объединения добавляет ветку. Когда вместо [channel, channel] стоит экран кода, ветки превращаются в три копии этого экрана. Второй путь, as Expected, короче и хуже: компилятор перестаёт проверять вообще всё.

Распределение по месту

Объединение по членам раскладывает только условный тип, об этом раздел «Распределение по объединению». Для одной функции такой тип пишется прямо:

type PairOfEach<C> = C extends unknown ? Pair<C> : never;

const pairOfEach = <C extends Channel>(c: C): PairOfEach<C> =>
  [c, c] as unknown as PairOfEach<C>;

Работает, но с ценой. Тело с условным возвращаемым типом компилятор не сверяет, поэтому без двойного каста не обойтись. И условный тип пишется заново под каждую функцию: ReceiptOfEach, RouteOfEach, и в каждом та же строчка C extends unknown ? ... : never. Отличается только то, что стоит после ?.

Отличается одна вещь, конструктор Pair<_>. Распределение хочется написать один раз и передать ему конструктор параметром. Это та самая стена из Раздела 1: параметр, который пробегает не по типам, а по конструкторам типов.

Лямбда над значением

В Effect для этого есть Struct.Lambda. Схема та же, что у TypeLambda, только слотов два: вход ~lambda.in и выход ~lambda.out. Применяет лямбду Struct.Apply:

// effect/Struct
export interface Lambda {
  readonly '~lambda.in': unknown;
  readonly '~lambda.out': unknown;
}

export type Apply<L extends Lambda, V> = (L & { readonly '~lambda.in': V })['~lambda.out'];

Сравни с Kind из Раздела 3: пересечение с объектом, где слоту присвоен аргумент, и чтение результата. Тот же трюк с this, другие имена.

Лямбда для пары несёт и вызов, и функцию над типами:

import { Struct } from 'effect';

interface AsPair extends Struct.Lambda {
  <T extends Channel>(channel: T): Pair<T>;
  readonly '~lambda.out': Pair<this['~lambda.in']>;
}

const asPair = Struct.lambda<AsPair>((c) => [c, c] as const);

Сигнатура вызова говорит, как лямбду вызвать, а ~lambda.out говорит, что выйдет для любого входа. В рантайме Struct.lambda возвращает ту же стрелку и меняет только тип.

Распределение пишется один раз на весь проект:

type Distribute<L extends Struct.Lambda, In> = In extends unknown ? Struct.Apply<L, In> : never;

const distribute =
  <L extends Struct.Lambda & ((input: never) => unknown)>(make: L) =>
  <In extends Parameters<L>[0]>(input: In) =>
    (make as (input: In) => unknown)(input) as Distribute<L, In>;

Distribute это PairOfEach, у которого Pair вынесен в параметр L. Каст никуда не делся, но он один и живёт в помощнике, а не в бизнес-логике. In extends Parameters<L>[0] берёт допустимый вход из сигнатуры лямбды, поэтому distribute(asPair)('fax') не скомпилируется.

const distributed: Expected = distribute(asPair)(channel);

Ошибки нет: у distributed тип Pair<'email'> | Pair<'sms'> | Pair<'push'>. Та же asPair подходит и к Struct.map, которая применяет лямбду к каждому полю структуры: Struct.map({ a: 'email' as const }, asPair) даёт поле a типа Pair<'email'>. Конструктор типа стал значением, и его можно отдавать разным помощникам.

Где кончается проверка

Тело лямбды компилятор сверяет со стёртой сигнатурой. Struct.lambda смотрит на Parameters и ReturnType, а у обобщённой сигнатуры они схлопываются в (c: Channel) => Pair<Channel>. Поэтому скомпилируется и такая лямбда:

const liar = Struct.lambda<AsPair>((c) => ['email', c] as const);

На 'sms' она вернёт ['email', 'sms'] под типом Pair<'sms'>. Связь слотов записана один раз, в интерфейсе AsPair, и держишь её ты, как и лямбду в TypeLambda (Раздел 8). Выигрыш в том, что обещание одно и лежит рядом с объявлением, а не размазано по веткам и кастам.

Когда аргументов больше одного

Struct.Lambda держит один вход. В next-midi-demo фабрика элемента берёт два аргумента, и объединение приходит во втором. Поэтому в пакете @evadev/effect-helpers автор завёл свою LambdaAllArgs: слот ~lambda.inAllArgsTuple хранит весь кортеж аргументов, а ~lambda.out читает из него нужную позицию, например this['~lambda.inAllArgsTuple'][1]. Устройство то же, исходник в distributeMake.ts.

Здесь нет ни монады, ни второго контейнера. Есть свойство, которого не хватало: два слота обязаны совпадать по члену объединения. Правило из Раздела 8 не меняется, инструмент берут ради такого свойства. Просто нужно оно не только авторам библиотек.

Раздел 8 · грабли

Слот In у Effect и Stream всегда never. Их каналы A, E, R все выходные, входного нет. Поэтому I1 & I2 в composeK для них схлопывается в never & never, и это правильно. Глядя на код общего вида, легко решить, что In несёт что-то полезное. Несёт он только у типов-функций.

Лямбду никто не сверяет с контейнером. Если в ResultTypeLambda написать readonly type: Option<this['Target']>, компилятор не пожалуется: синтаксически это корректное TypeLambda. Ошибка вылезет потом, в странном разрешении Kind где-то далеко. Лямбда это контракт, который ты держишь сам.

Вариантность несёт нагрузка. Effect<out A, out E, out R> объявлен с out именно поэтому: Kind складывает выходные слоты через |, а | осмысленно только на ковариантных. Убери out с параметра, и разрешение Kind развалится на ровном месте.

Инференс неиспользованных слотов даёт unknown. Когда контейнер не смотрит на In, вывод типов оставляет там unknown или never, и это может протечь в сообщение об ошибке. Если тип не выводится сам, помоги явной аннотацией, а не кастом.

as внутри инстанса прячет ошибку. EffectUnified и StreamUnified собираются без кастов, и это признак здоровья: сигнатуры библиотечных функций действительно совпали. Как только в таблице появился as never, значит одна из операций на самом деле не подходит под контейнер, и лучше это увидеть сейчас.

Один контейнер это не повод. Если по всему проекту живёт только Effect и обобщать не над чем, Monad<F>, Kind3 и Unified добавят три экрана типов и ноль пользы. Правило то же, что в 24 · стандартная библиотека: инструмент берут ради свойства, которого не хватает.

Ниже withFallback из Раздела 5 и третий инстанс из упражнения e27.2. Раннер проверяет поведение, а не типы, поэтому тесты в песочнице на сервере гоняют одну функцию на Effect, Stream и Result.

Финал · чек-лист

Чек-листготово

ДЗ

Задания делаются в pulse-<nick>. После каждого открой PR с тегом lesson-27.

Дальше

  • 02-cs · 07 Typeclasses и HKT. Первый способ, URI-trick из fp-ts. Теперь видно, чем он отличается от TypeLambda: глобальная таблица против лямбды на каждый контейнер.
  • 25 · 09 Stream и 25 · 23 Sinks и advanced stream. StreamTypeLambda это тот самый Stream<A, E, R> из этих уроков, здесь он впервые попал в общий вид.
  • 25 · 24 Стандартная библиотека. Result и Option там разобраны как данные, здесь они стали двумя инстансами одной абстракции.
  • 04-ts · 03 Type-level вглубь. Если хочется читать такие типы без запинки, это прямое продолжение темы условных типов и инференса.
  • 02-cs · 14 Линзы и оптики. Ван Ларховен-кодировка линз упирается в ту же стену отсутствия HKT, и там показано, как её обходят.

домашка

Домашка