Skip to content

“A type predicate's type must be assignable to its parameter's type” does not always kick in #32541

@karol-majewski

Description

@karol-majewski

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_isDog looks 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions