Problema con propiedades genéricas cuando se asigna mapeo

11

Tengo una biblioteca que exporta un tipo de utilidad similar al siguiente:

type Action<Model extends object> = (data: State<Model>) => State<Model>;

Este tipo de utilidad le permite declarar una función que se realizará como una "acción". Recibe un argumento genérico Modelcontra el cual operará la acción.

El dataargumento de la "acción" se escribe con otro tipo de utilidad que exporto;

type State<Model extends object> = Omit<Model, KeysOfType<Model, Action<any>>>;

El Statetipo de utilidad básicamente toma el Modelgenérico entrante y luego crea un nuevo tipo donde Actionse han eliminado todas las propiedades que son de tipo .

Por ejemplo, aquí hay una implementación básica de usuario de lo anterior;

interface MyModel {
  counter: number;
  increment: Action<Model>;
}

const myModel = {
  counter: 0,
  increment: (data) => {
    data.counter; // Exists and typed as `number`
    data.increment; // Does not exist, as stripped off by State utility 
    return data;
  }
}

Lo anterior está funcionando muy bien. 👍

Sin embargo, hay un caso con el que estoy luchando, específicamente cuando se define una definición de modelo genérico, junto con una función de fábrica para producir instancias del modelo genérico.

Por ejemplo;

interface MyModel<T> {
  value: T; // 👈 a generic property
  doSomething: Action<MyModel<T>>;
}

function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    doSomething: data => {
      data.value; // Does not exist 😭
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

En el ejemplo anterior, espero que el dataargumento se escriba donde doSomethingse eliminó la acción y la valuepropiedad genérica aún existe. Sin embargo, este no es el caso: valuenuestra propiedad también ha eliminado la propiedad State.

Creo que la causa de esto es que Tes genérico sin que se apliquen restricciones / restricciones de tipo, y por lo tanto, el sistema de tipos decide que se cruza con un Actiontipo y posteriormente lo elimina del datatipo de argumento.

¿Hay alguna forma de evitar esta restricción? He investigado un poco y esperaba que hubiera algún mecanismo en el que pudiera afirmar que Thay alguno, excepto un Action. es decir, una restricción de tipo negativa.

Imagina:

function modelFactory<T extends any except Action<any>>(value: T): UserDefinedModel<T> {

Pero esa característica no existe para TypeScript.

¿Alguien sabe de alguna manera que podría hacer que esto funcione como lo espero?


Para ayudar a la depuración, aquí hay un fragmento de código completo:

// Returns the keys of an object that match the given type(s)
type KeysOfType<A extends object, B> = {
  [K in keyof A]-?: A[K] extends B ? K : never
}[keyof A];

// Filters out an object, removing any key/values that are of Action<any> type
type State<Model extends object> = Omit<Model, KeysOfType<Model, Action<any>>>;

// My utility function.
type Action<Model extends object> = (data: State<Model>) => State<Model>;

interface MyModel<T> {
  value: T; // 👈 a generic property
  doSomething: Action<MyModel<T>>;
}

function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    doSomething: data => {
      data.value; // Does not exist 😭
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

Puedes jugar con este ejemplo de código aquí: https://codesandbox.io/s/reverent-star-m4sdb?fontsize=14

ctrlplusb
fuente

Respuestas:

7

Este es un problema interesante. Typecript generalmente no puede hacer mucho con respecto a los parámetros de tipo genérico en tipos condicionales. Simplemente difiere cualquier evaluación de extendssi encuentra que la evaluación involucra un parámetro de tipo.

Se aplica una excepción si podemos obtener el mecanografiado para usar un tipo especial de relación de tipo, a saber, una relación de igualdad (no una relación extendida). Una relación de igualdad es fácil de entender para el compilador, por lo que no es necesario diferir la evaluación de tipo condicional. Las restricciones genéricas son uno de los pocos lugares en el compilador donde se usa la igualdad de tipos. Veamos un ejemplo:

function m<T, K>() {
  type Bad = T extends T ? "YES" : "NO" // unresolvable in ts, still T extends T ? "YES" : "NO"

  // Generic type constrains are compared using type equality, so this can be resolved inside the function 
  type Good = (<U extends T>() => U) extends (<U extends T>() => U) ? "YES" : "NO" // "YES"

  // If the types are not equal it is still un-resolvable, as K may still be the same as T
  type Meh = (<U extends T>()=> U) extends (<U extends K>()=> U) ? "YES": "NO" 
}

Enlace de juegos

Podemos aprovechar este comportamiento para identificar tipos específicos. Ahora, esta será una coincidencia de tipo exacta, no una coincidencia de extensión, y las coincidencias de tipo exacto no siempre son adecuadas. Sin embargo, dado que Actiones solo una firma de función, las coincidencias de tipo exactas podrían funcionar lo suficientemente bien.

Veamos si podemos extraer tipos que coincidan con una firma de función más simple como (v: T) => void:

interface Model<T> {
  value: T,
  other: string
  action: (v: T) => void
}

type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

function m<T>() {
  type M = Model<T>
  type KeysOfIdenticalType = {
    [K in keyof M]: Identical<M[K], (v: T) => void, never, K>
  }
  // Resolved to
  // type KeysOfIdenticalType = {
  //     value: Identical<T, (v: T) => void, never, "value">;
  //     other: "other";
  //     action: never;
  // }

}

Enlace de juegos

El tipo anterior KeysOfIdenticalTypeestá cerca de lo que necesitamos para filtrar. Para other, se conserva el nombre de la propiedad. Para el action, se borra el nombre de la propiedad. Solo hay un problema molesto value. Dado que valuees de tipo T, no es trivialmente resoluble T, y (v: T) => voidno son idénticos (y de hecho pueden no serlo).

Todavía podemos determinar que valuees idéntico a T: para propiedades de tipo T, intersecte esta verificación (v: T) => voidcon never. Cualquier intersección con neveres trivialmente resoluble a never. Luego podemos volver a agregar propiedades de tipo Tusando otra verificación de identidad:

interface Model<T> {
  value: T,
  other: string
  action: (v: T) => void
}

type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

function m<T>() {
  type M = Model<T>
  type KeysOfIdenticalType = {
    [K in keyof M]:
      (Identical<M[K], (v: T) => void, never, K> & Identical<M[K], T, never, K>) // Identical<M[K], T, never, K> will be never is the type is T and this whole line will evaluate to never
      | Identical<M[K], T, K, never> // add back any properties of type T
  }
  // Resolved to
  // type KeysOfIdenticalType = {
  //     value: "value";
  //     other: "other";
  //     action: never;
  // }

}

Enlace de juegos

La solución final se parece a esto:

// Filters out an object, removing any key/values that are of Action<any> type
type State<Model extends object, G = unknown> = Pick<Model, {
    [P in keyof Model]:
      (Identical<Model[P], Action<Model, G>, never, P> & Identical<Model[P], G, never, P>)
    | Identical<Model[P], G, P, never>
  }[keyof Model]>;

// My utility function.
type Action<Model extends object, G = unknown> = (data: State<Model, G>) => State<Model, G>;


type Identical<T, TTest, TTrue, TFalse> =
  ((<U extends T>(o: U) => void) extends (<U extends TTest>(o: U) => void) ? TTrue : TFalse);

interface MyModel<T> {
  value: T; // 👈 a generic property
  str: string;
  doSomething: Action<MyModel<T>, T>;
  method() : void
}


function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    str: "",
    method() {

    },
    doSomething: data => {
      data.value; // ok
      data.str //ok
      data.method() // ok 
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

/// Still works for simple types
interface MyModelSimple {
  value: string; 
  str: string;
  doSomething: Action<MyModelSimple>;
}


function modelFactory2(value: string): MyModelSimple {
  return {
    value,
    str: "",
    doSomething: data => {
      data.value; // Ok
      data.str
      data.doSomething; // Does not exist 👍
      return data;
    }
  };
}

Enlace de juegos

NOTAS: La limitación aquí es que esto solo funciona con un parámetro de tipo (aunque posiblemente se pueda adaptar a más). Además, la API es un poco confusa para cualquier consumidor, por lo que esta podría no ser la mejor solución. Puede haber problemas que aún no he identificado. Si encuentra alguno, hágamelo saber 😊

Tiziano Cernicova-Dragomir
fuente
2
Siento que Gandalf el Blanco acaba de revelarse. 🤯 TBH Estaba listo para escribir esto como una limitación del compilador. Así que me alegro de probar esto. ¡Gracias! 🙇
ctrlplusb
@ctrlplusb 😂 LOL, ese comentario me alegró el día 😊
Titian Cernicova-Dragomir
Tenía la intención de aplicar la recompensa a esta respuesta, pero tengo una severa falta de sueño, el cerebro del bebé continúa y hice un clic incorrecto. ¡Mis disculpas! Esta es una respuesta fantásticamente perspicaz. Aunque bastante complejo en la naturaleza. 😅 Muchas gracias por tomarse el tiempo para responder.
ctrlplusb
@ctrlplusb :( Oh, bueno ... ganar algo perder algo :)
Titian Cernicova-Dragomir
2

Sería genial si pudiera expresar que T no es de tipo Acción. Una especie de inverso de se extiende

Exactamente como dijiste, el problema es que todavía no tenemos restricciones negativas. También espero que puedan obtener esa característica pronto. Mientras espero, propongo una solución como esta:

type KeysOfNonType<A extends object, B> = {
  [K in keyof A]-?: A[K] extends B ? never : K
}[keyof A];

// CHANGE: use `Pick` instead of `Omit` here.
type State<Model extends object> = Pick<Model, KeysOfNonType<Model, Action<any>>>;

type Action<Model extends object> = (data: State<Model>) => State<Model>;

interface MyModel<T> {
  value: T;
  doSomething: Action<MyModel<T>>;
}

function modelFactory<T>(value: T): MyModel<T> {
  return {
    value,
    doSomething: data => {
      data.value; // Now it does exist 😉
      data.doSomething; // Does not exist 👍
      return data;
    }
  } as MyModel<any>; // <-- Magic!
                     // since `T` has yet to be known
                     // it literally can be anything
}
hackape
fuente
No es ideal, pero es bueno saber de una solución alternativa :)
ctrlplusb
1

county valuesiempre hará que el compilador sea infeliz. Para solucionarlo, puede intentar algo como esto:

{
  value,
  count: 1,
  transform: (data: Partial<Thing<T>>) => {
   ...
  }
}

Como Partialse está utilizando el tipo de utilidad, estará bien en el caso de que el transformmétodo no esté presente.

Stackblitz

Lucas
fuente
1
"el recuento y el valor siempre harán que el compilador sea infeliz": agradecería una idea de por qué aquí. xx
ctrlplusb
1

Generalmente leo eso dos veces y no entiendo completamente lo que quieres lograr. Según tengo entendido, desea omitir transformdel tipo que se le da exactamente transform. Para lograr eso es simple, necesitamos usar Omitir :

interface Thing<T> {
  value: T; 
  count: number;
  transform: (data: Omit<Thing<T>, 'transform'>) => void; // here the argument type is Thing without transform
}

// 👇 the factory function accepting the generic
function makeThing<T>(value: T): Thing<T> {
  return {
    value,
    count: 1,
      transform: data => {
        data.count; // exist
        data.value; // exist
    },
  };
}

No estoy seguro de si esto es lo que quería debido a la gran complejidad que ha dado en los tipos de utilidad adicionales. Espero eso ayude.

Maciej Sikora
fuente
Gracias, si lo deseo. Pero este es un tipo de utilidad que estoy exportando para consumo de terceros. No sé la forma / propiedades de sus objetos. Solo sé que necesito quitar todas las propiedades de las funciones y utilizar el resultado contra el argumento de transformación de datos de funciones.
ctrlplusb
He actualizado la descripción de mi problema con la esperanza de que sea más claro.
ctrlplusb
2
El problema principal es que T también puede ser de tipo Acción, ya que no está definido para excluirlo. La esperanza encontrará alguna solución. Pero estoy en el lugar donde el recuento está bien, pero T todavía se omite porque es una intersección con Acción
Maciej Sikora
Sería genial si pudiera expresar que T no es de tipo Acción. Una especie de inverso de se extiende.
ctrlplusb
Discusión relativa: stackoverflow.com/questions/39328700/…
ctrlplusb