I'm trying my hand at porting Scott Wlaschin's "Railway Oriented Programming" concept to Typescript.
I'm trying to get the types correct for the either function. I think my code below should work, but I'm getting compiler errors on the return statements in the either function.
type Success<T> = {
success: true,
result: T
};
type Failure<E> = {
success: false,
cause: E
}
export function either<T, E, U, F, I extends Success<T> | Failure<E>>(
onSuccess: (i: T) => U,
onFailure: (i: E) => F,
input: I
): I extends Success<T> ? U : I extends Failure<E> ? F : never {
if (input.success === true) {
return onSuccess(input.result); // compiler error: Type 'U' is not assignable to type 'I extends Success<T> ? U : I extends Failure<E> ? F : never'
} else {
return onFailure(input.cause); // compiler error: Type 'F' is not assignable to type 'I extends Success<T> ? U : I extends Failure<E> ? F : never'
}
}
I'm unsure of the cause of this error, as the conditional type states that U and F can be returned, and my code results in the correct type narrowing of the type of the input argument. I can get rid of them by using a Type Assertion that matches the return type of the function (e.g. return onSuccess(input.result) as I extends Success<T> ? ....), but my questions are:
- Is this the only way to get rid of the compiler errors?
- If so, why can't typescript determine that the return values satisfy the specified return type?
Currently TypeScript's control flow anlaysis does not affect generic type parameters. If you have a value
tof a generic typeT, thenswitch/caseorif/elseor?/:type guardingtcan narrow the apparent type oftfromTto something else (e.g.,T & string), but it cannot do anything to the type parameterT.This is for good reason: you can't just re-constrain
T. Let's sayT extends A | B, and you checkisA(t)which narrowsttoA(orT & A). This does not imply thatT extends A.Tmight beA, or it might be the full union typeA | B. We've established a new lower bound forT, not a new upper bound. Maybe that would be written likeT super A extends A | B, if TypeScript had a way to express lower bound constraints, as requested in microsoft/TypeScript#14520. But it doesn't, so we're kind of stuck, until and unless that is ever implemented.You might be thinking that the intent of
T extends A | Bis thatTshould be exactly one ofAorB, and that unions of those possibilities should be excluded from consideration in the first place. That would be nice, but it's not whatextendsmeans. We'd need a new sort of constraint for that, as requested in microsoft/TypeScript#27808. Let's call itoneof. Then you could writeT oneof A | Band checkingisA(t)would indeed re-constrainTtoT oneof A. But again, this is not currently possible, so we have to wait or work around it.By far the easiest workaround is just to use type assertions to claim that
isA(t)really does imply thatTisA.But you asked "is this the only way to get rid of the compiler errors?" and the answer to that is "no but you should probably do it anyway".
Right now if you want to use generics and have dependent-like types without type assertions, you'd need to refactor so that it doesn't use control flow analysis, but instead performs one of the few generic operations TypeScript can verify. Mostly this involves indexing with a generic key type into some object type or mapped types on that object type. The general approach is outlined in microsoft/TypeScript#47109.
Unfortunately for your case the refactoring is fairly awful and obtrusive. You can't index into objects with
booleanvalues, so we'd need to change to some string/number/enum type instead:And then you'd need to rewrite your types to always involve indexing into explicit mapped types over objects with these pseudo-boolean keys. Maybe like this:
Yuck. But at least it compiles with no error and no type assertions. Let's test it out to make sure it still behaves as expected:
Looks good, so at least callers of
eitherhave the same experience, uh, if you ignore the boolean thing.There you go. Personally I'd probably just stick with type assertions and maybe in some future version of TypeScript, when control flow analysis effects generic type parameters, and you can remove them.
Playground link to code