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;
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