fp-ts icon indicating copy to clipboard operation
fp-ts copied to clipboard

Branching `if` statement in `Either.chainW` doesn't union properly

Open atomanyih opened this issue 3 years ago • 6 comments

🐛 Bug report

Current Behavior

Note: example has been replaced with a more representative case

type StoreError = {
  type: 'storeError';
  excuse: string;
};

type Hotdog = {
  type: 'hotdog';
  length: 'small' | 'medium' | 'large';
  name: string;
};

const getHotdog = (length: Hotdog['length']): E.Either<StoreError, Hotdog> => {
  return E.of({
    type: 'hotdog',
    length,
    name: 'Cindy',
  })
}

type Sausage = {
  type: 'sausage';
  length: 'small' | 'medium' | 'large';
  name: string;
};

const getSausage = (length: Sausage['length']): E.Either<StoreError, Sausage> => {
  return E.of({
    type: 'sausage',
    length,
    name: 'Addison',
  })
}

function getMediumHotdogOrSausage(type: 'hotdog' | 'sausage') {
  return pipe(
    E.of(type),
    E.chainW((type) => {
// TS2345: Argument of type '(type: "hotdog" | "sausage") => Left<StoreError> | Right<Hotdog> | Right<Sausage>' is not assignable to parameter of type '(a: "hotdog" | "sausage") => Either<StoreError, Hotdog>'.
//   Type 'Left<StoreError> | Right<Hotdog> | Right<Sausage>' is not assignable to type 'Either<StoreError, Hotdog>'.
//     Type 'Right<Sausage>' is not assignable to type 'Either<StoreError, Hotdog>'.
//       Type 'Right<Sausage>' is not assignable to type 'Right<Hotdog>'.
//         Type 'Sausage' is not assignable to type 'Hotdog'.
//           Types of property 'type' are incompatible.
//             Type '"sausage"' is not assignable to type '"hotdog"'.
      if (type === 'hotdog') {
        return getHotdog('medium');
      }

      return getSausage('small');
    }),
  );
}

Expected behavior

I would expect this to pass type-check and result in a type E.Either<StoreError, Sausage | Hotdog>.

Is there a better way to represent a branch like this?

Your environment

Which versions of fp-ts are affected by this issue? Did this work in previous versions of fp-ts?

Software Version(s)
fp-ts 2.11.8
TypeScript 4.5.5

atomanyih avatar Apr 28 '22 16:04 atomanyih

Why do you not use E.map instead? E.of is just an alias for E.right so if you only return Right a chain is unnecessary.

mlegenhausen avatar Apr 29 '22 07:04 mlegenhausen

@mlegenhausen Thanks for the response! It's true that this example can be simplified that way. I tried to create a simple example to demonstrate the type issue, but it may have ended up too contrived. In the real world usage, it does need to be chain, as each of the branches could fail.

Here's a somewhat more representative example:

type StoreError = {
  type: 'storeError';
  excuse: string;
};

type Hotdog = {
  type: 'hotdog';
  length: 'small' | 'medium' | 'large';
  name: string;
};

const getHotdog = (length: Hotdog['length']): E.Either<StoreError, Hotdog> => {
  return E.of({
    type: 'hotdog',
    length,
    name: 'Cindy',
  })
}

type Sausage = {
  type: 'sausage';
  length: 'small' | 'medium' | 'large';
  name: string;
};

const getSausage = (length: Sausage['length']): E.Either<StoreError, Sausage> => {
  return E.of({
    type: 'sausage',
    length,
    name: 'Cindy',
  })
}

function getMediumHotdogOrSausage(type: 'hotdog' | 'sausage') {
  return pipe(
    E.of(type),
    E.chainW((type) => {
// TS2345: Argument of type '(type: "hotdog" | "sausage") => Left<StoreError> | Right<Hotdog> | Right<Sausage>' is not assignable to parameter of type '(a: "hotdog" | "sausage") => Either<StoreError, Hotdog>'.
//   Type 'Left<StoreError> | Right<Hotdog> | Right<Sausage>' is not assignable to type 'Either<StoreError, Hotdog>'.
//     Type 'Right<Sausage>' is not assignable to type 'Either<StoreError, Hotdog>'.
//       Type 'Right<Sausage>' is not assignable to type 'Right<Hotdog>'.
//         Type 'Sausage' is not assignable to type 'Hotdog'.
//           Types of property 'type' are incompatible.
//             Type '"sausage"' is not assignable to type '"hotdog"'.
      if (type === 'hotdog') {
        return getHotdog('medium');
      }

      return getSausage('small');
    }),
  );
}

atomanyih avatar Apr 29 '22 16:04 atomanyih

Thanks for the better example.

This is not a fp-ts related problem. You can get the same error when exchanging Either for example with Array which maybe makes the problem more obvious.

import * as A from 'fp-ts/Array'

function getMediumHotdogOrSausage(type: 'hotdog' | 'sausage') {
  return pipe(
    A.of(type),
    A.chain(type => {
	  // Argument of type '(type: "hotdog" | "sausage") => string[] | number[]' is not assignable to parameter of type '(a: "hotdog" | "sausage") => string[]'.
      //  Type 'string[] | number[]' is not assignable to type 'string[]'.
      //    Type 'number[]' is not assignable to type 'string[]'.
      //      Type 'number' is not assignable to type 'string'.ts(2345)
      if (type === 'hotdog') {
        return A.of('abc')
      }

      return A.of(123)
    })
  )
}

The problem is simply that the first return type TypeScript sees is a string[] but you want to assign a number[] in the second one which typescript interprets in an error on your side. You can work around this error by providing a return type so typescript knows that you want to actually return a combined array type. In this case you can write (string | number)[].

So in your case you need to provide the return type E.Either<StoreError, Sausage | Hotdog> to your arrow function so typescript knows that it should combine Sausage and Hotdog and not treat every Either on its own.

mlegenhausen avatar May 02 '22 09:05 mlegenhausen

Thanks for the explanation! So when you say "it's not an fp-ts-related problem" do you mean it's inherent to the type system in some way? Because typescript doesn't seem to have a problem doing if statement normally. This works fine:

function stupidFunctionForExample(a: number[]) {
  return pipe(
    a,
    (a) => {
      if (a.length > 1) {
        return a.map((v) => v.toString());
      }

      return a;
    }
  );
}

Underlying explanation aside, my solution to this problem was to write a custom combinator:

export function branchW<T, TRefined extends T, D1, D2, E1, E2, T1, T2>(
  predicate: (val: T) => val is TRefined,
  onFalse: (val: T) => RTE.ReaderTaskEither<D1, E1, T1>,
  onTrue: (val: TRefined) => RTE.ReaderTaskEither<D2, E2, T2>,
): <E0, D0>(
  rte: RTE.ReaderTaskEither<D0, E0, T>,
) => RTE.ReaderTaskEither<D0 & D1 & D2, E0 | E1 | E2, T1 | T2>;
export function branchW<T, D1, D2, E1, E2, T1, T2>(
  predicate: (val: T) => boolean,
  onFalse: (val: T) => RTE.ReaderTaskEither<D1, E1, T1>,
  onTrue: (val: T) => RTE.ReaderTaskEither<D2, E2, T2>,
): <E0, D0>(
  rte: RTE.ReaderTaskEither<D0, E0, T>,
) => RTE.ReaderTaskEither<D0 & D1 & D2, E0 | E1 | E2, T1 | T2>;
export function branchW<T, D1, D2, E1, E2, T1, T2>(
  predicate: (val: T) => boolean,
  onFalse: (val: T) => RTE.ReaderTaskEither<D1, E1, T1>,
  onTrue: (val: T) => RTE.ReaderTaskEither<D2, E2, T2>,
) {
  return <E0, D0>(
    rte: RTE.ReaderTaskEither<D0, E0, T>,
  ): RTE.ReaderTaskEither<D0 & D1 & D2, E0 | E1 | E2, T1 | T2> => {
    return pipe(
      rte,
      RTE.chainW((v) => {
        if (predicate(v)) {
          return onTrue(v) as RTE.ReaderTaskEither<D1 | D2, E1 | E2, T1 | T2>;
        }

        return onFalse(v) as RTE.ReaderTaskEither<D1 | D2, E1 | E2, T1 | T2>;
      }),
    );
  };
}

This fulfills our specific use case of "I want to follow a different path of logic depending on a condition" and "I want to infer the return type so I don't have to explicitly declare all errors and dependencies"

atomanyih avatar May 11 '22 22:05 atomanyih

Yes, but I don't know exactly why it is implemented the way it is.

The difference is that the stupidFunctionForExample does not conditionally change the inner type of your Monad. You can see that because the return type of the function is number[] | string[] if you try to return that in the chain example you get a type error, but you can return this type in your stupidFunctionForExample. So number[] | string[] is a subset of (number | string)[] but not the other way around.

mlegenhausen avatar May 12 '22 07:05 mlegenhausen