Is it impossible to have a function return a user-defined type guard?

Viewed 384

Consider the following type and sub-types:

interface Animal {
  type: string;
}

interface Dog extends Animal {
  type: 'dog';
  sound: 'bark';
}

interface Cat extends Animal {
  type: 'cat';
  purpose: 'world_domination';
}

There's also a way to get an Animal sub-type from the value of type:

type Animals = Dog | Cat;
type AnimalFromType<T extends Animals['type']> = Extract<Animals, { type: T }>;

Now let's say I have an array of animals, and I want to create a function that returns a user-defined type guard which can then be used in an Array.filter() call:

function createFilter<T extends 'dog' | 'cat'>(type: T): (x: any) => x is AnimalFromType<T> {
  return x => x.type === type;
}

The problem is that it does not seem possible to have functions return type guard functions - the compiler complains with TS2322 that (x: any) => boolean is not assignable to (x: any) => x is AnimalFromType<T> - signature must be a type predicate. For some reason, the compiler does not understand that the returned boolean expression from the lambda matches the x is AnimalFromType<T> part of the outer function's signature.

Why is that? What would stop the compiler from understanding that createFilter('dog') returns a user-defined type guard (which takes an object and returns x is Dog)?

Edit: Full MRE provided by @jcalz here: https://tsplay.dev/wX219m

2 Answers

See microsoft/TypeScript#14826 for an authoritative answer.

If you want the compiler to see that a boolean value is assignable to a type predicate, you need to use it as the return value of a function whose return type is explicitly annotated to be that type predicate. That's the only method supported by the type checker.

User-defined type guards and their type predicates do not propagate in TypeScript, so any intuition you have about something like "transitivity of type predicates" is probably not going to pan out. So you get this behavior:

function isString(x: any): x is string {
  return typeof x === "string"; // okay
}

function isStringMaker(): (x: any) => x is string {
  return (x: any) => typeof x === "string"; // error
}

because the function (x: any) => typeof x === string does not have an annotated return value of a type predicate type. The compiler just infers (x: any) => boolean for it, and everything breaks. There's no contextual typing that says "since isStringMaker() wants to return a function that returns a type predicate, we should try to interpret typeof x === string as such a type predicate because it's in the relevant place." Maybe such a thing could be implemented, but it isn't, and it seems this is by design to sort of require a very local and explicit expression of intent around type predicates.

To do that with isStringMaker, we can annotate the return type of the returned arrow function:

function isStringMaker(): (x: any) => x is string {
  return (x: any): x is string => typeof x === "string"; // okay
  // ----------> ^^^^^^^^^^^^^ <--- return type annotation
}

It's not documented anywhere obvious I can find in the handbook that you can annotate the return type of an arrow function this way, but it is mentioned in ms/TS#14826 linked above, so you can at least consult that. Anyway once you do that you don't really need to annotate the isStringMaker() return type anymore, since that will be inferred... so if you care about redundancy you can eliminate it there:

function isStringMaker() {
  return (x: any): x is string => typeof x === "string"; 
}

Therefore, my suggestion would be to change your createFilter() so that the actual type guard function has a return type annotation:

function createFilter<T extends 'dog' | 'cat'>(type: T) {
  return (x: any): x is AnimalFromType<T> => x.type === type;
}

Playground link to code

Perhaps a simple instanceof could solve your problem. You can use it in the array filter:

animals.filter(animal => animal instanceof Dog )

I used classes to define the types.

interface Animal {
  type: string;
  name: string;
}

class Cat implements Animal {
  type: string;
  name: string;
  purpose: string;

  constructor(name: string) {
    this.type = 'cat';
    this.name = name;
    this.purpose = 'world_domination';
  }
}

class Dog implements Animal {
  type: string;
  name: string;

  constructor(name: string) {
    this.type = 'dog';
    this.name = name;
    this.sound = 'bark';
  }
}

const animals = [ new Dog('dog0'), new Cat('cat0'), new Dog('dog1'), new Cat('cat1')]

console.log(animals.filter(animal => animal instanceof Dog ))
Related