Búsqueda de sitios web

typescript - ¿Es posible evitar la aserción de tipo en el cuerpo de esta función genérica?


Como caso de uso, quiero permitir que las personas agreguen una función arbitraria a un objeto y llamen a esa función a través de otra, usando su nombre y parámetros.

// Example arbitrary function
const sum = (a: number, b: number): number => a + b

// Another such function
const saySomething = (): string => Math.random() < 0.5 ? 'morning' : 'evening'

// This object holds the functions declared above
const execFn: Record<string, (...args: any[]) => any> = { sum, saySomething }

// These functions can be called through a generic function
export const exec = (name: string, ...args: any[]): any => execFn[name](...args)

console.log(exec('sum', 1, 2))

Esto funciona, pero no es seguro en cuanto al tipo. Por ejemplo, alguien puede introducir los parámetros incorrectos. Puedo mejorar esto usando tipos mapeados, de la siguiente manera:

type GenericFn = keyof typeof execFn

type GenericParameters = {
  [K in GenericFn]: Parameters<(typeof execFn)[K]>
}

type GenericReturn = {
  [K in GenericFn]: Parameters<(typeof execFn)[K]>
}

Y luego modificando la firma de la función exec:

// These functions can be called through a generic function
export const exec = <T extends GenericFn>(
  name: T,
  ...args: GenericParameters[T]
): GenericReturn[T] => (execFn[name] as (...args: any[]) => any)(...args)

Y ahora, TS indicará correctamente si alguien está llamando a una función sin los parámetros correctos, y el tipo de retorno está correctamente tipado.

console.log(exec('sum', 1)) // Expected 3 arguments, but got 2. ts(2554)

¿Cómo puedo eliminar la afirmación de tipo en la función exec entonces?

// These functions can be called through a generic function
export const exec = <T extends GenericFn>(
  name: T,
  ...args: GenericParameters[T]
): GenericReturn[T] => execFn[name](...args)

Eliminar la aserción de tipo genera el siguiente error de TS.

Type 'string | number' is not assignable to type 'GenericReturn[T]'.
  Type 'string' is not assignable to type 'GenericReturn[T]'.
    Type 'string' is not assignable to type 'never'.
      The intersection '[a: number, b: number] & []' was reduced to 'never'
      because property 'length' has conflicting types in some constituents.ts(2322)

Tratar de aislar la función me da la impresión de que la función genérica no está obteniendo la función exacta del objeto.

const fn = execFn[name]

// const fn: {
//   sum: (a: number, b: number) => number;
//   saySomething: () => string;
// }[T]

Área de juego de TypeScript

Respuesta #1

Esto en realidad está bastante cerca del enfoque recomendado para tratar con ciertos tipos de dependencias de entrada-salida, como se describe en microsoft/TypeScript#47109. Para que el siguiente código funcione:

export const exec = <K extends GenericFn>(
  name: K,
  ...args: GenericParameters[K]
): GenericReturn[K] => execFn[name](...args)

el compilador necesita ver execFn[name] como una única función de tipo genérico (...args: GenericParameters[K]) => GenericReturn[K]. Lo que significa que necesita ver que execFn es algo que se comporta de manera genérica cuando se indexa por una clave de tipo K.

Según microsoft/TypeScript#47109, esto ocurrirá si execFn se representa como un tipo mapeado sobre las claves K en GenericFn. Específicamente:

const execFn: { 
  [K in GenericFn]: (...args: GenericParameters[K]) => GenericReturn[K] 
} = ⋯;

Dado que tus tipos GenericParameters y GenericReturn están escritos en términos de typeof execFn, no podemos hacer eso directamente sin circularidad. Renombremos tu execFn a _execFn:

const _execFn = {
  isEven, randomInt, repeat, saySomething, sum,
} as const;

type GenericFn = keyof typeof _execFn

type GenericParameters = {
  [K in GenericFn]: Parameters<(typeof _execFn)[K]>
}

type GenericReturn = {
  [K in GenericFn]: ReturnType<(typeof _execFn)[K]>
}

Y ahora podemos asignar _execFn a execFn del tipo apropiado:

const execFn: {
  [K in GenericFn]: (...args: GenericParameters[K]) => GenericReturn[K]
} = _execFn;

Y eso funciona.


Tenga en cuenta que esto depende de la forma del tipo de execFn. Si observa el tipo de execFn a través de IntelliSense:

/* const execFn: {
    isEven: (n: number) => boolean;
    randomInt: (upTo: number) => number;
    repeat: (word: string, times: number) => string[];
    saySomething: () => string;
    sum: (a: number, b: number) => number;
} */

y compáralo con el tipo mostrado para _execFn:

/* const _execFn: {
    readonly isEven: (n: number) => boolean;
    readonly randomInt: (upTo: number) => number;
    readonly repeat: (word: string, times: number) => string[];
    readonly saySomething: () => string;
    readonly sum: (a: number, b: number) => number;
} */

esos son básicamente los mismos (readonly no importa aquí). Y por supuesto que deben serlo, de lo contrario la asignación const execFn: ⋯=_execFn fallaría.

Pero la representación interna de los tipos es diferente. El tipo de execFn es explícitamente un tipo mapeado en el cual cada propiedad tiene una relación genérica, mientras que el tipo de _execFn es solo una lista de propiedades posiblemente no relacionadas. El compilador carece de la capacidad de mirar _execFn y derivar por sí mismo el tipo de execFn; necesita que se le indique explícitamente.

Entonces, el tipo de execFn[name] es una única función que acepta una lista de parámetros de tipo GenericParameters[K], lo cual es bueno. Pero el tipo de _execFn[name] termina ampliándose a una unión de funciones, lo cual es bastante inútil, ya que las uniones de funciones solo se pueden llamar de manera segura con una intersección de argumentos (ver las notas de la versión de TS3.3). Por lo tanto, _execFn[name] requiere una lista de parámetros de tipo never (es decir, no hay una manera segura de llamar a esta unión de funciones porque ningún argumento funciona para cada firma de llamada).

De hecho, el problema que finalmente llevó al enfoque microsoft/TypeScript#47109 estaba relacionado con este tipo de uniones inútiles de funciones, como se describe en microsoft/TypeScript#30581. Ese problema se plantea en términos de "uniones correlacionadas", donde tienes un valor v de un tipo de unión como {k: "sum", fn: (a: number, b: number) => number, args: [a: number, b: number]} | {k: "saySomething", fn:() => string, args: []} | ⋯} y quieres llamar a v.fn(...v.args), pero el compilador no te lo permite. Los índices genéricos son parte de la solución, pero también necesitas tener tipos mapeados.

Enlace de Playground al código