Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generics: Cannot limit template param to specific types #20235

Closed
UselessPickles opened this issue Nov 23, 2017 · 8 comments
Closed

Generics: Cannot limit template param to specific types #20235

UselessPickles opened this issue Nov 23, 2017 · 8 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@UselessPickles
Copy link

UselessPickles commented Nov 23, 2017

Generics currently allow you to restrict the type of a template param to any type that extends some type, but as far as I know, there's no way to restrict the type of the template param to specific types.

For example, I'm trying to create a generic type where the template param is one of any specific type in a discriminated union, but the compiler doesn't treat the type as a discriminated union within generic code. Here's is a super simplified example:

interface Cat {
    type: "cat"
    meow(): void;
}

interface Fish {
    type: "fish"
    swim(): void;
}

// discriminated union
type Animal = Cat | Fish;

// template param should really be limited to exactly Animal (Cat or Fish)
interface AnimalWrapper<T extends Animal> {
    animal: T;
}

function foo<T extends Animal>(animalWrapper: AnimalWrapper<T>) {
    if (animalWrapper.animal.type === "cat") {
        // ERROR: Property 'meow' does not exist on type 'T'  
        animalWrapper.animal.meow()
    } else {
        // ERROR: Property 'swim' does not exist on type 'T'  
        animalWrapper.animal.swim()
    }
}

Is it reasonable to expect that my code example should work, and that this is a bug? Or is there some technicality about T extending Animal rather than being Animal that prevents the compiler from making the typical assumptions that would allow my code example to work?

On the surface, it's hard for me to imagine how anything could possibly extend Animal, and have any value for type other than "cat" or "fish". Shouldn't the compiler be able to determine in my "if cat" statement that animalWrapper.animal is indeed something that extends Cat, and therefore must have the "meow" method?

Would it be reasonable to expand the syntax of Generics to be able to limit the template param to specific types? Here's what I imagine:

// "is" would indicate that "T" must be exactly one of the types in the "Animal" union.
// Or maybe it would make more sense to use "in" instead of "is"?
interface AnimalWrapper<T is Animal> {
    animal: T;
}

function foo<T is Animal>(animalWrapper: AnimalWrapper<T>) {
    // Now that "T" is limited specifically to Cat or Fish, the following code should be valid
    if (animalWrapper.animal.type === "cat") {
        // Compiler should now know BOTH:
        // 1) animalWrapper.animal is of type 'Cat'
        // 2) animalWrapper is of type AnimalWrapper<Cat>
        animalWrapper.animal.meow()
    } else {
        animalWrapper.animal.swim()
    }
}
@Proculopsis
Copy link

Here is a workaround in the Playground: https://goo.gl/u3tUDW

@UselessPickles
Copy link
Author

@Proculopsis Your "foo" function is only working because it is not using the template type:

// The Template type 'T' is never used
function foo<T extends Animal>(animal: Animal) {
    if (isCat(animal)) {
        animal.meow()
    } else {
        animal.swim()
    }
}

If you change the method signature to animal: T, then you get a compiler error at animal.swim().

@Proculopsis
Copy link

Here is another attempt in the Playground: https://goo.gl/grTLZ1

Sorry, I tried to simplify your example - the above implements AnimalWrapper and includes isFish() because the transpiler doesn't know that isCat()/else is exhaustive.

@UselessPickles
Copy link
Author

UselessPickles commented Nov 23, 2017

Yeah, I'm basically doing the same thing - using custom type guards to force the compiler to know what type it is wherever necessary.

One problem with this workaround is that you lose the ability to test against "never" to guarantee that all possible cases have been covered by the code:

/**
 * Use for compile-time validation that you have handled all possibilities for
 * a value. 
 * Throw the return value for error reporting in case an invalid
 * value from an external data source makes its way through at runtime. 
 * @param value - The value that should never have a possible value/type at the
 *        point where this function is called.
 * @param name - A name for the value, used only for constructing the error
 *        message for the returned error.
 * @return an Error that you may choose to throw for runtime error reporting.
 */
function assertNever(value: never, name?: string): Error {
    return new Error(
        `${name || "value"} was expected to be "never",` +
        ` but it is "${value}" of type "${typeof value}"`
    );
}

function foo<T extends Animal>(animalWrapper: AnimalWrapper<T>) {
    console.log(animalWrapper);
    if (isCat(animalWrapper.animal)) {
        animalWrapper.animal.meow();
    } else if (isFish(animalWrapper.animal)) {
        animalWrapper.animal.swim();
    } else {
        // ERROR: Argument of type 'T' is not assignable to parameter of type 'never'.
        //              Type 'Animal' is not assignable to type 'never'.
        //              Type 'Cat' is not assignable to type 'never'.
        //
        // I expect it to compile because all possibilities are handled above.
        throw assertNever(animalWrapper.animal, "animalWrapper.animal");
    }
}

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Aug 7, 2018
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Aug 7, 2018

Would it be reasonable to expand the syntax of Generics to be able to limit the template param to specific types?

This is effectively what generics already are? There's another proposal floating around for upper-bounded generics which may be what you want, but it's hard to tell.

It's hard to speak to this issue specifically because the type parameter isn't really doing anything in the example. In most cases where you actually use the type parameter in a way that accomplishes something (correlating two parameters, two properties in the same parameter, or a parameter to a return type) it can be rewritten in a way that works the way you expect while still doing the right thing.

The salient thing to mention is that we can't safely say that the type of AnimalWrapper<T extends Animal>.animal is actually Cat even if its type is "cat"! You could have an AnimalWrapper<FancyCat>, at which point it would be unsound to allow you to assign a non-Fancy cat to the .animal property.

@UselessPickles
Copy link
Author

UselessPickles commented Aug 8, 2018

@RyanCavanaugh Actual use cases I have are more complex and make use of the type parameter more extensively, but this example is simplified to point out specifically the issue I experienced with expecting the discriminated union type Animal to "work" as a type parameter, such that type narrowing would happen when testing the discriminator.

Your example of a FancyCat would have to be assignable to either Dog or Cat (probably Cat), so I would still expect the compiler to understand that if animalWrapper.animal.type === "cat", then animalWrapper.animal must have the properties defined in the Cat interface.

@UselessPickles
Copy link
Author

UselessPickles commented Aug 8, 2018

I see what you mean now about assigning a wrong type to animalWrapper.animal.

I think this is part of why I was suggesting some way to limit the generic type param to EXACTLY the Animal type union, rather than anything that is assignable to Animal. But I understand that everything in the type system is about assignability of "shapes" of types, rather that exact types themselves, so that's probably never going to happen.

So maybe ignore my suggested solution and just look at my problem: why can't the compiler figure out that that the meow() method exists on animalWrapper.animal after testing that animalWrapper.animal.type === "cat"? Can this problem be solved somehow within the design of the type system?

@sstokker-yopeso
Copy link

@UselessPickles a bit of a late reply, but recently I came across a very similar problem, and someone suggested doing this, whenever the TS compiler can't distinguish between union-types -- worked like a charm for me:

(animalWrapper.animal as Cat).meow()
(animalWrapper.animal as Fish).swim()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

4 participants