Búsqueda de sitios web

typescript - ¿hay alguna manera de usar TS extends para influir en el tiempo de ejecución?


La cláusula extends en TS es una ayuda poderosa para que tus tipos sean manipulados de la misma manera que se modifican tus estructuras de datos en tiempo de ejecución. De hecho, a veces es una forma más compacta y poderosa de hacer esto... lo que luego me lleva a la esperanza de que podría haber alguna manera de informar al sistema en tiempo de ejecución sobre los tipos.

Aquí hay un ejemplo simple de lo que me gustaría poder hacer:

const toFooOrNotToFoo = ifTypeOf(myVariable).extends<string>();

En el ejemplo anterior, es bastante fácil inferir el tipo de myVariable y luego en el sistema de tipos determinar si la relación de extends se cumple, pero ¿cómo podemos llevar eso de vuelta al sistema en tiempo de ejecución? Secretamente sospecho que esto podría ser un puente demasiado lejano, pero sería útil, así que le estoy preguntando a la comunidad.

Mi primer intento de una solución fue un intento audaz de combinar casting, inferencia y el operador keyof para hacer la magia. Terminó en lágrimas y me revocaron la licencia para practicar magia. Después de años de terapia y manejo de la ira, eventualmente tomé un giro más práctico y decidí que necesitaría una variable de tiempo de ejecución representativa de cada tipo para poder efectuar la comparación en tiempo de ejecución junto a la comparación de tipos.

function runtimeExtendsCheck<
  TValue extends any,
  TBase extends any
>(val: TValue, base: TBase, narrow: boolean = false): TValue extends TBase ? true : false {
  if (typeof val !== typeof base) {
    return false as TValue extends TBase ? true : false;
  }

  switch (typeof val) {
    case "boolean":
    case "string":
    case "number":
    case "symbol":
    case "bigint":
      return narrow
        ? (val === base) as TValue extends TBase ? true : false
        : true as TValue extends TBase ? true : false;
    case "undefined":
      return true as TValue extends TBase ? true : false;
    case "function":
        ? (val.toString() === (base as Function).toString()) as TValue extends TBase ? true : false
        : true as TValue extends TBase ? true : false;
    case "object":
      if (val === null && base === null) {
        return true as TValue extends TBase ? true : false;
      }
      return keys(base as object).every(i => runtimeExtendsCheck(val[i], base[i], narrow)) as TValue extends TBase ? true : false;
  }
}

Y con este verificador en tiempo de ejecución, luego añadí la siguiente API en tiempo de ejecución:

const ifTypeOf = <
  N extends Narrowable, 
  TValue extends Record<keyof TValue, N> | number | string | boolean | symbol
>(val: TValue) => ({
  extends: <TBase extends any>(base: TBase) => {
    const valid = runtimeExtendsCheck(val, base, false);
    return (valid ? true : false) as TValue extends TBase ? true : false;
  },

  narrowlyExtends: <NB extends Narrowable, TBase extends Record<keyof TBase, NB> | number | string | boolean | symbol>(base: TBase) => {
    const valid = runtimeExtendsCheck(val, base, true);
    return (valid ? true : false) as TValue extends TBase ? true : false;
});

Esta API entonces me permite hacer algo como lo siguiente:

const done = ifTypeOf({
  wakeUp: true
}).extends({
  wakeUp: true, 
  eatBreakfast: true, 
  drinkCoffee: true
});

Ahora puede que aún no esté claro, pero la comparación que se está haciendo es contra un objeto con propiedades cuyo valor es boolean y no true. Este es un comportamiento esperado para la API que creamos, pero probablemente queríamos el valor literal true. Por ejemplo, imagina esto:

const done = ifTypeOf({
  wakeUp: false, 
  eatBreakfast: false, 
  drinkCoffee: false
}).extends({
  wakeUp: true, 
  eatBreakfast: true, 
  drinkCoffee: true
});

La variable done todavía es true cuando la observación casual indicaría que debería ser false. Podemos empeorar aún más nuestra situación si tomamos lo que parece el paso sensato de agregar as const de esta manera:

const done = ifTypeOf({
  wakeUp: false, 
  eatBreakfast: false, 
  drinkCoffee: false
}).extends({
  wakeUp: true, 
  eatBreakfast: true, 
  drinkCoffee: true
} as const);

Ahora el sistema de tipos está completamente seguro de que done es de hecho false, pero el tiempo de ejecución tiene la opinión completamente opuesta de que es true.

Para evitar esto, hemos añadido la parte narrowlyExtends de la API y esto permite que funcione con:

const done = ifTypeOf({
  wakeUp: false, 
  eatBreakfast: false, 
  drinkCoffee: false
}).narrowlyExtends({
  wakeUp: true, 
  eatBreakfast: true, 
  drinkCoffee: true
} as const);

Esto funciona para este caso de uso y en caso de que ahora te sientas bien, déjame decirte que esto ni siquiera es suficientemente bueno. ¿Qué pasa con las funciones? Realmente no hay forma, fuera de posiblemente algún tipo de análisis AST (esto casi seguro es una mala idea), de que realmente puedas aproximar cuál es la estructura de una función (es decir, tipos de parámetros y tipo de retorno).

Así que esta idea de hacer esto "en paralelo" claramente tiene sus límites. Puede que se adapte a algunos casos de uso, pero es un poco peligrosa y simplemente no puede hacer algunas cosas que podríamos querer. La cuestión es... ¿es este "el camino" (como podrían decir el Mandaloriano y Confucio)?

Esto me lleva de vuelta al punto de partida de mi sueño idealista... ¿hay alguna manera de que el sistema de tipos informe los valores en tiempo de ejecución para que los tipos puedan dictar una lógica única en lugar de depender de este monstruo de Frankenstein? Si esto no es posible, ¿alguien sabe si alguien ha desarrollado un mejor Frankenstein?

Código de laboratorio

Nota: disculpas si esto fue un poco largo, pero he estado tratando de resolver este problema mientras lo redactaba y sentí que el detalle extra podría valer la pena expresarlo para que algunos comprendan los errores de este enfoque dual