Search code examples
typescripttypescript-genericstypescript-typestypeguards

Is there a way to express this TypeScript type guard, that involves Generics, without getting a "subtype of constraint" error?


I would like to use a Required/Pick Generic combination to define whether a property (name) on Cat/Dog exists. Then, I would like to create a type guard function that determines whether or not a given Cat/Dog has that property.

However, I get the below error on the NamedAnimal in the return type of the function.

Is there a way to express the below concept in TypeScript, of combining a Required/Pick Generic and a type guard, without an error?

Code tried:

export type AugmentedRequired<T extends object, K extends keyof T = keyof T> = Omit<T, K> &
  Required<Pick<T, K>>;

type Cat = { name?: boolean };
type Dog = { name?: boolean };

type Animal = Cat | Dog;

type NamedAnimal<T extends Animal = Animal> = AugmentedRequired<T, 'name'>;

export function isNamedAnimal<T extends Animal = Animal>(animal: T): animal is NamedAnimal<T> { // Error is on NamedAnimal<T> in this line
     return 'name' in animal;
}

Error on this code:

A type predicate's type must be assignable to its parameter's type.
  Type 'NamedAnimal<T>' is not assignable to type 'T'.
    'NamedAnimal<T>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Animal'.

If this is a known unsupported concept, is there a clean-ish way to suppress the error?

Additional note:

One way that I have found to do this is to use a plain inline function that does not have the type-checking of a type guard, like below. But it seems like I should be able to explicitly define this concept.

export const isNamedAnimal = (animal: Animal) => 'name' in animal;

Solution

  • In order for a type predicate of the form t is U to be allowed as a return type, TypeScript must be able to see that U is assignable to typeof t to start with. That is, U must be considered a narrowing of t, or that U extends typeof T. If Type is some unrelated type, or a wider type, or a type that's too complicated for TypeScript to see as narrower, then you'll get an error.

    You have a generic type T, and are hoping that TypeScript will see Omit<T, "name"> & Required<Pick<T, "name">> is a narrowing of T. This doesn't happen. The Omit utility type is implemented in terms of the Exclude utility type, which is implemented as a conditional type. And while TypeScript can evaluate Omit<T, "name"> for any specific T, it cannot do so for generic T. If T is generic, TypeScript just leaves Omit<T, "name"> effectively unevaluated. It is deferred. Your intent was that Omit<T, "name"> & Required<Pick<T, "name">> is performing a sort of "surgery" on T that, when the surgery is over, results in something assignable to T. But TypeScript cannot see that intent. This is considered a missing feature of TypeScript, described at microsoft/TypeScript#28884. Maybe some future version of TypeScript will support this, but right now, it doesn't happen. So you get that error.

    Whenever you have a situation where TypeScript fails to see that U extends T but you're sure it does, you can usually replace U with something that TypeScript does see as extending T, and which will evaluate to U if you are correct. For example, the intersection T & U is definitely assignable to T. And if U extends T is true, then T & U should be equivalent to U. Or you could use the Extract utility type, like Extract<U, T>. So you can get your existing code to compile if you change it to

    declare function isNamedAnimal<T extends Animal>(
        animal: T): animal is T & NamedAnimal<T>; // okay
    

    or

    declare function isNamedAnimal<T extends Animal>(
        animal: T): animal is Extract<NamedAnimal<T>, T>; // okay
    

    But in your case, you really don't need to go through this trouble, because there's no real need to use Omit in the first place. The type T & Required<Pick<T, "name">> is effectively identical to Omit<T, "name"> & Required<Pick<T, "name">>. The intersection of a required property and an optional property is a required property, so there's no reason to remove a possibly-optional property before the intersection. Indeed, T & NamedAnimal<T> that makes things work above can be seen as T & Omit<T, "name"> & Required<Pick<T, "name">> and if that's identical to NamedAnimal<T>, then you can just use that, and Omit<T, "name"> is absorbed by the intersection.

    Therefore, you can change your code to this:

    type AugmentedRequired<T extends object, K extends keyof T = keyof T> = 
      T & Required<Pick<T, K>>;
    

    And your original code works as-is:

    declare function isNamedAnimal<T extends Animal>(
        animal: T): animal is NamedAnimal<T>; // okay
    

    Playground link to code