Search code examples
typescripttypeguardsintersection-types

Why is the behavior inconsistent when intersecting type guards?


I want to use type guards so that hasNext can filter out types.

type Queue1 = {
  operation: 'move';
  offset: number;
};

type Queue2 = {
  operation: 'eat';
  food: string;
};

type Queue3 = {
  operation: 'run';
  type: 'fast' | 'slow';
};

type ExampleQueue = Queue1 | Queue2 | Queue3;

export class ExampleEvent {
  queue: Array<ExampleQueue>;

  constructor(queue: Array<ExampleQueue> = []) {
    this.queue = queue;
  }

  get event() {
    return this.queue[0];
  }

  enqueue(value: ExampleQueue) {
    this.queue.push(value);
  }

  dequeue() {
    return this.queue.shift();
  }

  size() {
    return this.queue.length;
  }

  hasNext<T extends ExampleQueue['operation'] = ExampleQueue['operation']>(
    operation?: T
  ): this is ExampleEvent & { event: Extract<ExampleQueue, { operation: T }>; dequeue(): Extract<ExampleQueue, { operation: T }> } {
    if (this.size() <= 0) return false;
    return operation ? this.event.operation === operation : true;
  }
}

I wrote the code above and used it as shown below.

const event = new ExampleEvent();
if (event.hasNext('move')) {
    event.event.offset // correct!
    event.dequeue().offset // error!
}

Why does it work for event.event.offset but not event.dequeue().offset.type?

hasNext<T extends ExampleQueue['operation'] = ExampleQueue['operation']>(
  operation?: T
): this is { event: Extract<ExampleQueue, { operation: T }>; dequeue(): Extract<ExampleQueue, { operation: T }> } & ExampleEvent {
  if (this.size() <= 0) return false;
  return operation ? this.event.operation === operation : true;
}

What's unusual is that when I write it like above, it validates correctly. I don't know what's wrong, it's just that the order is reversed from above.

Also, if I write it like below, I get the result I want.

hasNext<T extends ExampleQueue['operation'] = ExampleQueue['operation']>(
  operation?: T
): this is Omit<ExampleEvent, 'event' | 'dequeue'> & { event: Extract<ExampleQueue, { operation: T }>; dequeue(): Extract<ExampleQueue, { operation: T }> } {
  if (this.size() <= 0) return false;
  return operation ? this.event.operation === operation : true;
}

Solution

  • Intersections of function types are treated as overloads. When you call an overloaded function, TypeScript has to choose an appropriate call signature... and it generally does so by checking each one in order and selecting the first one that matches how you called it. That allows you to write things like

    // overloaded function with two call signatures
    declare function foo(a: string): string;
    declare function foo(a: unknown): number;
    
    foo("abc").toUpperCase(); // okay, (a: string) => string selected
    foo(123).toFixed(); // okay, (a: unknown) => number selected
    

    Note that the above will behave completely differently if you reorder the call signatures:

    declare function foo(a: unknown): number;
    declare function foo(a: string): string;
    
    foo("abc").toUpperCase(); // error! (a: unknown) => number selected
    

    So overloads are, in general, order dependent, and intersections of functions are equivalent to overloads. That explains the behavior you're seeing.

    Yes, it's weird, since intersections are conceptually order-independent, but functions are an exception to this.


    In particular, this fails because event.dequeue has two zero-arg call signatures, the first one of which returns ExampleQueue | undefined:

    if (event.hasNext('move')) {
        /* const event: ExampleEvent & {
            event: Queue1;
            dequeue(): Queue1;
        } */
        type Dequeue = typeof event.dequeue
        // type Dequeue = (() => ExampleQueue | undefined) & (() => Queue1)
        event.dequeue().offset // error!
    }
    

    If you switch the order of the intersection, it succeeds because now the first zero-arg call signature returnes Queue1:

    if (event.hasNext('move')) {
        /* const event: {
             event: Queue1;
             dequeue(): Queue1;
        } & ExampleEvent */
        type Dequeue = typeof event.dequeue
        event.dequeue().offset // okay
        // type Dequeue = (() => Queue1) & (() => ExampleQueue | undefined)
    }
    

    But even that really isn't what you want, since you don't intend for there to be two call signatures. So if you Omit the original call signature, then you also get a success:

    if (event.hasNext('move')) {
        /* const event: Omit<ExampleEvent, "event" | "dequeue"> & {
             event: Queue1;
             dequeue(): Queue1;
        } */
        type Dequeue = typeof event.dequeue
        // type Dequeue = () => Queue1
        event.dequeue().offset // okay
    }
    

    And so it's this third approach that conforms to what you're looking for, and how I'd recommend writing this.

    Playground link to code