Búsqueda de sitios web

typescript - Cómo tipar un objeto dinámico con métodos con un número dinámico de argumentos


Tengo un método:

const invokeActions = (target, actions) => {
  actions.forEach(({ action, args }) => {
    target[action](...args);
  });
}

¿Cómo escribir este método correctamente para que el compilador muestre errores por el número incorrecto de argumentos y los tipos de argumentos de la función llamada?

Ejemplo de uso:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

const actions = [
  { action: 'moveTo', args: [0, 0] },
  { action: 'lineTo', args: [100, 100] },
  { action: 'closePath', args: ['error'] }, // Should return a TypeScript compiler error
  { action: 'closePath', args: [1, 2] },    // Same thing
];

invokeActions(ctx, actions);

La primera vez intenté escribir mediante interface pero closePath tomaba [] y también [number, number], lo cual no es correcto.

interface Drawable {
  moveTo: (x: number, y: number) => void;
  lineTo: (x: number, y: number) => void;
  closePath: () => void;
}

type ActionType = {
  action: Drawable[keyof Drawable] extends (...args: any[]) => void ? keyof Drawable : never;
  args: Drawable[keyof Drawable] extends (...args: any[]) => void ? Drawable[keyof Drawable] : never;
}

También intenté muchas combinaciones usando infer en (...args: infer Args), traté de hacer args: Parameters<T[keyof T]> pero TypeScript hizo lo mismo o simplemente muestra errores.

Respuesta #1

Para que esto funcione desde el lado del llamador, el tipo de invokeActions() necesita verse así:

declare const invokeActions: 
  (target: Drawable, actions: Action[]) => void

type Drawable = {
  moveTo: (x: number, y: number) => void;
  lineTo: (x: number, y: number) => void;
  closePath: () => void;
}

type Action = 
  { action: "moveTo"; args: [x: number, y: number]; } | 
  { action: "lineTo"; args: [x: number, y: number]; } | 
  { action: "closePath"; args: []; }

El tipo Action es una unión discriminada donde la propiedad action actúa como el discriminante. Eso te dará el comportamiento deseado:

const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) throw new Error(); // make sure it's not null
invokeActions(ctx, [
  { action: 'moveTo', args: [0, 0] },
  { action: 'lineTo', args: [100, 100] },
  { action: 'closePath', args: ['error'] }, // error!
  // -------------------------> ~~~~~~~
  // Type 'string' is not assignable to type 'number'
  { action: 'closePath', args: [1, 2] } // error!
  //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ <--
  // Type '[number, number]' is not assignable to type '[]'.
]);

Por supuesto, te gustaría poder calcular estos tipos en lugar de escribirlos manualmente. Esto es bastante fácil si comenzamos con una interfaz de mapeo desde las propiedades action al tipo args:

interface DrawableArgs {
  moveTo: [x: number, y: number];
  lineTo: [x: number, y: number];
  closePath: [];
}

Luego podemos definir Drawable y Action en términos de ellos:

type Drawable =
  { [K in keyof DrawableArgs]: (...args: DrawableArgs[K]) => void }

type Action<K extends keyof DrawableArgs = keyof DrawableArgs> =
  { [P in K]: { action: P, args: DrawableArgs[P] } }[K]

Tenga en cuenta que Drawable ahora es un tipo mapeado sobre DrawableArgs, mientras que Action es un tipo de objeto distributivo según se acuñó en microsoft/TypeScript#47109. Si usted aumenta DrawableArgs, entonces Drawable y Action se actualizarán automáticamente.


Lo que queda es hacer que esto funcione desde el lado del implementador. Esto es un poco complicado, porque si solo anotas obtendrás un error dentro del callback de forEach():

const invokeActions = (
  target: Drawable, actions: Action[]) => {
  actions.forEach(({ action, args }) => {
    target[action](...args); // error
    //             ~~~~~~~
    // A spread argument must either have a tuple type or be passed to a rest parameter
  });
}

Esto está sucediendo porque target[action] y args se interpretan como si tuvieran dos tipos de unión independientes. El compilador ha perdido la correlación entre ellos, por lo que no puede estar seguro de que estés usando los argumentos que realmente corresponden a la acción; le preocupa que tal vez action sea, por ejemplo, "closePath", mientras que args sea [number, number]. Esto no puede ocurrir, pero el compilador pierde el seguimiento y no puede estar seguro.

Esta falta de soporte directo para tipos de unión correlacionados es el tema de microsoft/TypeScript#30581. Y microsoft/TypeScript#47109 (mencionado arriba) detalla el enfoque recomendado para tales casos... que es refactorizar alejándose de las uniones y hacia los genéricos. La idea es usar una interfaz "base" y representar todas tus operaciones en términos de índices genéricos dentro de esa interfaz y de tipos mapeados sobre esa interfaz.

De hecho, el tipo DrawableArgs funciona como la interfaz base, y Drawable y Action son los tipos mapeados apropiados y los índices genéricos en tipos mapeados. Podríamos haber comenzado con Drawable y derivar Action a partir de él, pero si lo hiciéramos, no cumpliría con microsoft/TypeScript#47109, y el compilador no podría seguirlo.

De todos modos, lo único que queda por hacer es asegurarse de que el callback de forEach() sea realmente genérico en K extends keyof DrawableArgs:

const invokeActions = (
  target: Drawable, actions: Action[]) => {
  actions.forEach(<K extends keyof DrawableArgs>({ action, args }: Action<K>) => {
    target[action](...args); // okay
  });
}

Eso funciona porque ahora action es de tipo genérico K. Así que target[action] se considera de tipo Drawable[K], lo que se evalúa como (...args: DrawableArgs[K]) => void. Y args es de tipo DrawableArgs[K], por lo que la llamada está permitida.

Enlace de Playground al código