Search code examples
typescriptgenericstype-inferencetypechecking

TypeScript generics: union of generic properties


I have a notion of a step. It takes an input and produces an output.

This is a simplified example to demonstrate the problem (so it might be lacking some extra beef with correct type restrictions, etc). We are interested in the andThen function which accumulates state as it goes through steps. No questions to this block of code, it is just for reference.

type SubType<T, U> = T extends U ? U : never;

class Step<A, B> {
  constructor(private readonly f: (a: A) => B) { }

  public run(a: A): B {
    return this.f(a);
  }

  public andThen<C, D>(nextStep: Step<SubType<B, C> | B, D>): Step<A, B & D> {
    return new Step<A, B & D>((state: A) => {
      const b = this.f(state);
      return { ...b, ...nextStep.run(b) };
    });
  }
}

The following works just fine, we have two steps, types are inferred automatically and we end up with all having expected type { user1: User, user2: User }

type User = { id: number, name: string };

const user1 = new Step((input: {}) => ({ user1: { id: 1, name: "A" } }));
const user2 = new Step((input: {}) => ({ user2: { id: 2, name: "B" } }));

const all = user1.andThen(user2).run({})

My next step was to reuse the same abstraction when dealing with the same function which produces result, but the only difference I would like to have is the key under which the result goes. You probably didn't understand what I just said, so let me show you the code:

class UserStep<K extends string> extends Step<{}, { [k in K]: User }> { }

const user21 = new UserStep<"user1">((input: {}) => ({ user1: { id: 1, name: "A" } }));
const user22 = new UserStep<"user2">((input: {}) => ({ user2: { id: 2, name: "B" } }));

// inferred type here is wrong { user1: User } instead of { user1: User, user2: User }
const all2 = user21.andThen(user22).run({})

UserStep should handle creation of the user (or whatever other job it needs to do) and I would like it to return the result in an object with key K extends string. Once again everything type checks automatically and seems to be working BUT there is a problem with all2 where the type is inferred incorrectly (most likely it is correct, but it is different from expected). I understand that it is most likely has to do something with how the UserStep is defined and its key is K extends string but I fail to see what other approach I could have taken to make this UserStep work as expected.

So the question is: is there a way to abstract UserStep so that it returns whatever the result it wants with different keys, so that after composing steps using andThen type checker infers correct/expected type?


Solution

  • UPDATE: the error below has been fixed in the latest build of TypeScript and is likely to be released with TS3.8. At that point you should be able to just use the "most reasonable typing" of Step<A, B> below, without union or SubType workarounds, and everything should 🤞 just work.


    The underlying issue here is that the type checker doesn't realize that the most reasonable typing of Step<A, B>, namely:

    interface Step<A, B> {
        f: (a: A) => B;
        andThen<C>(next: Step<B, C>): Step<A, B & C>;
    }
    

    is contravariant in A. Meaning that Step<T, B> is assignable to Step<U, B> if and only if U is assignable to T. The switch in assignability direction is the "contra" part. Had it been the same direction, as in "F<T> is assignable to F<U> if and only if T is assignable to U", it would be covariant.

    Anyway, that means you get an error here:

    declare const u1: Step<{}, { user1: {} }>;
    declare const u2: Step<{}, { user2: {} }>;
    let u1u2 = u1.andThen(u2); // error!?
    // Type 'Step<{}, any>' is not assignable to type 'Step<{ user1: {}; }, any>'.
    // Type '{}' is not assignable to type '{ user1: {}; }'.
    

    The reason it doesn't realize that Step<A, B> is contravariant in A is because Step<A, B>'s andThen() method involves Step itself, and when checking variance for type parameters in such recursive generic types, the type checker assumes it to be covariant, as mentioned in a comment inside the typeArgumentsRelatedTo() function in checker.ts:

    // When variance information isn't available we default to covariance

    (Thanks to jack-williams for his helpful comment about this)

    This is either a bug or a design limitation; ideally the compiler would not make any default guess and instead check for structural compatibility. I don't think there are any existing GitHub issues surrounding this particular problem, so I have filed microsoft/TypeScript#35805 to ask about it. The general lack of ability for developers to add variance hints is covered in microsoft/TypeScript#1394.


    Anyway, to deal with this it looks like you used a workaround to make the andThen() method not trip up the variance checker while still relating A contravariantly:

        type SubType<T, U> = T extends U ? U : never;
        interface Step<A, B> {
            f: (a: A) => B;
            andThen<C, X>(next: Step<B | SubType<B, X>, C>): Step<A, B & C>;
        }
        declare const u1: Step<{}, { user1: {} }>;
        declare const u2: Step<{}, { user2: {} }>;
        let u1u2 = u1.andThen(u2); // okay
    

    Unfortunately, as you've seen, type inference in the face of generic conditional types like SubType can be messy:

        type User = { id: number, name: string };
        interface UserStep<K extends string> extends Step<{}, { [k in K]: User }> { }
        declare const v1: UserStep<"user1">;
        declare const v2: UserStep<"user2">;
        let v1v2 = v1.andThen(v2); // Step<{}, {user1: User;}> !!
    

    The easiest "fix" to this is just to manually specify the type parameters it doesn't infer properly:

        let v1v2Fixed = v1.andThen<{ user2: User }, {}>(v2); // okay:
        // Step<{}, { user1: User;} & { user2: User;}>
    

    A different workaround which I think has the same behavior around contravariance is this:

        interface Step<A, B> {
            f: (a: A) => B;
            andThen<C, X>(next: Step<B | X, C>): Step<A, B & C>;
        }
    

    But here there are no generic conditional types to worry about, and the compiler is much better at inferring things:

        let v1v2 = v1.andThen(v2); // okay:
        // Step<{}, { user1: User;} & { user2: User;}>
    

    So that might be my suggestion. You should really test it, though, since there are often crazy edge cases. Hopefully eventually the variance thing will just be fixed "the right way", but for now at least you have some options.


    Okay, hope that helps; good luck!

    Playground Link