fp-ts
fp-ts copied to clipboard
Branching `if` statement in `Either.chainW` doesn't union properly
🐛 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 |
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 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');
}),
);
}
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.
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"
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.