-
Notifications
You must be signed in to change notification settings - Fork 13.3k
Description
TypeScript Version: 3.6.0-dev.20190723
Search Terms: type guard, subtype, instantiated with a different subtype, identity
Code
interface Animal {
species: string;
}
interface Dog extends Animal {
bark(): void;
}
interface Cat extends Animal {
meow(): void;
}
declare const animal: Animal;
declare const dog: Dog;
declare const cat: Cat;
declare const mixed: Animal | Dog | Cat;
/**
* Good.
*/
declare function isDog(candidate: any): candidate is Dog;
/**
* Bad.
*/
function INCORRECT_isDog<T extends Animal>(candidate: T): candidate is Dog {
return ('bark' in candidate);
}
/**
* Ugly.
*/
function UNSAFE_isDog<T extends Animal>(candidate: T): candidate is T & Dog {
return ('bark' in candidate);
}
if (isDog(mixed)) {
mixed.bark(); // 👌
}
if (INCORRECT_isDog(cat)) {
cat.bark(); // Runtime error!
}
if (UNSAFE_isDog(cat)) {
cat.bark(); // Runtime error!
}Expected behavior:
UNSAFE_isDog would fail to compile just like INCORRECT_isDog. Asserting T to be of type T in the return type should not trick the compiler into thinking everything is fine.
Actual behavior:
UNSAFE_isDog compiles fine, which leads to runtime exceptions.
Playground Link: Click
Related issues: #24935, #30240
You can see the full use case below 👇.
Use case
Let's take a hierarchy like this:
interface Animal {
species: string;
}
interface Dog extends Animal {
bark(): void;
}
interface Cat extends Animal {
meow(): void;
}
declare const animal: Animal;
declare const dog: Dog;
declare const cat: Cat;The good
If we were to check whether a piece of data is a Dog, we could write a type guard.
Its signature could look like this:
declare function isDog(candidate: any): candidate is Dog;Our isDog would probably check for the existence of the species property to make sure it's
defined and of type string. So far so good.
The bad
Another way this can be done is by mixing runtime validation with compile-time validation. For
example, if we already know if something is an Animal, then it might be tempting to do less work
by checking only for the properties specific to Dogs.
This would require the argument to be an Animal.
function INCORRECT_isDog<T extends Animal>(candidate: T): candidate is Dog { // Compile-time error
return ("bark" in candidate);
}This won't work! We get a compile-time error.
Compile-time error: A type predicate's type must be assignable to its parameter's type.
Type 'Dog' is not assignable to type 'T'.
'Dog' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Animal'.
The error message makes perfect sense, because without the error we would be allowed to do:
if (INCORRECT_isDog(cat)) {
cat.bark(); // Runtime-error!
}The ugly
However, if we tweak the signature, the error message goes away.
function UNSAFE_isDog<T extends Animal>(candidate: T): candidate is T & Dog {
return ("bark" in candidate);
}This fails in runtime.
if (UNSAFE_isDog(cat)) {
cat.bark(); // Runtime error!
}Asserting the argument to be itself made the type checker shut up. It didn't make the code any safer though. Is that intended?
I'm aware that
- type guards are somewhat excluded from type checking
- it's my responsibility to make my own type guards correct
UNSAFE_isDoglooks horrible and feels like an abuse of the type system
but I'm having trouble convincing my team that UNSAFE_isDog is not the best way to go.