Search code examples
typescriptconditional-typestype-assertion

Validating whether return value satisfies Conditional Type in function signature


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:

  1. Is this the only way to get rid of the compiler errors?
  2. If so, why can't typescript determine that the return values satisfy the specified return type?

Solution

  • Currently TypeScript's control flow anlaysis does not affect generic type parameters. If you have a value t of a generic type T, then switch/case or if/else or ?/: type guarding t can narrow the apparent type of t from T to something else (e.g., T & string), but it cannot do anything to the type parameter T.

    This is for good reason: you can't just re-constrain T. Let's say T extends A | B, and you check isA(t) which narrows t to A (or T & A). This does not imply that T extends A. T might be A, or it might be the full union type A | B. We've established a new lower bound for T, not a new upper bound. Maybe that would be written like T 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 | B is that T should be exactly one of A or B, and that unions of those possibilities should be excluded from consideration in the first place. That would be nice, but it's not what extends means. We'd need a new sort of constraint for that, as requested in microsoft/TypeScript#27808. Let's call it oneof. Then you could write T oneof A | B and checking isA(t) would indeed re-constrain T to T 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 that T is A.


    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 boolean values, so we'd need to change to some string/number/enum type instead:

    enum Bool { FALSE, TRUE }
    const _true = Bool.TRUE;
    const _false = Bool.FALSE;
    

    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:

    interface BoolMap<T, F> {
        [_true]: T,
        [_false]: F
    }
    type Either<T, F, B extends Bool> = { [P in B]:
        { success: P } & BoolMap<{ result: T }, { cause: F }>[P]
    }[B]
    
    function either<T, F, TO, FO, B extends Bool>(
        onSuccess: (i: T) => TO,
        onFailure: (i: F) => FO,
        input: Either<T, F, B>
    ) {
        const func: { [P in Bool]:
            (i: Either<T, F, P>) => BoolMap<TO, FO>[P]
        } = {
            [_true]: v => onSuccess(v.result),
            [_false]: v => onFailure(v.cause)
        };
        return func[input.success](input);
    }
    

    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:

    const succ = (x: string) => x.length;
    const fail = (x: number) => x.toFixed(2);
    
    const r1 = either(succ, fail, { success: _true, result: "abc" });
    // const r1: number
    
    const r2 = either(succ, fail, { success: _false, cause: 404 });
    // const r2: string
    
    const r3 = either(succ, fail,
        Math.random() < 0.5 ?
            { success: _true, result: "abc" } :
            { success: _false, cause: 404 }
    );
    // const r3: string | number
    

    Looks good, so at least callers of either have 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