Búsqueda de sitios web

typescript - ¿Por qué el comportamiento es inconsistente al intersectar guardias de tipo?


Quiero usar guardias de tipo para que hasNext pueda filtrar los tipos.

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

Escribí el código anterior y lo usé como se muestra a continuación.

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

¿Por qué funciona con event.event.offset pero no con 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;
}

Lo inusual es que cuando lo escribo como arriba, se valida correctamente. No sé qué está mal, es solo que el orden está invertido respecto a lo anterior.

Además, si lo escribo como abajo, obtengo el resultado que quiero.

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

Respuesta #1

Las intersecciones de tipos de funciones se tratan como sobrecargas. Cuando llamas a una función sobrecargada, TypeScript tiene que elegir una firma de llamada apropiada... y generalmente lo hace verificando cada una en orden y seleccionando la primera que coincida con cómo la llamaste. Eso te permite escribir cosas como

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

Ten en cuenta que lo anterior se comportará de manera completamente diferente si reordenas las firmas de llamada:

declare function foo(a: unknown): number;
declare function foo(a: string): string;

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

Entonces, las sobrecargas son, en general, dependientes del orden, y las intersecciones de funciones son equivalentes a las sobrecargas. Eso explica el comportamiento que estás viendo.

Sí, es extraño, ya que las intersecciones son conceptualmente independientes del orden, pero las funciones son una excepción a esto.


En particular, esto falla porque event.dequeue tiene dos firmas de llamada sin argumentos, la primera de las cuales devuelve 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!
}

Si cambias el orden de la intersección, tiene éxito porque ahora la primera firma de llamada sin argumentos devuelve 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)
}

Pero incluso eso realmente no es lo que quieres, ya que no tienes la intención de que haya dos firmas de llamada. Así que si Omit la firma de llamada original, entonces también obtienes un éxito:

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
}

Y así que es este tercer enfoque el que se ajusta a lo que estás buscando, y cómo recomendaría escribir esto.

Enlace de Playground al código