Problème avec les propriétés génériques lors du mappage de type

11

J'ai une bibliothèque qui exporte un type d'utilitaire similaire au suivant:

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

Ce type d'utilitaire vous permet de déclarer une fonction qui fonctionnera comme une "action". Il reçoit un argument générique étant Modell'action contre laquelle l'action va fonctionner.

L' dataargument de "l'action" est ensuite tapé avec un autre type d'utilitaire que j'exporte;

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

Le Statetype d'utilitaire prend essentiellement le Modelgénérique entrant , puis crée un nouveau type où toutes les propriétés de type Actionont été supprimées.

Par exemple, voici une mise en œuvre de base des terres utilisateur de ce qui précède;

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

Ce qui précède fonctionne très bien. 👍

Cependant, il y a un cas avec lequel je me bats, en particulier lorsqu'une définition de modèle générique est définie, ainsi qu'une fonction d'usine pour produire des instances du modèle générique.

Par exemple;

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

Dans l'exemple ci-dessus, je m'attends à ce que l' dataargument soit tapé là où l' doSomethingaction a été supprimée et que la valuepropriété générique existe toujours. Ce n'est cependant pas le cas - la valuepropriété a également été retirée par notre Stateservice public.

Je crois que la cause de ceci est qu'il Test générique sans aucune restriction de type / rétrécissement qui lui est appliqué, et donc le système de type décide qu'il intersecte avec un Actiontype et le supprime ensuite du datatype d'argument.

Existe-t-il un moyen de contourner cette restriction? J'ai fait quelques recherches et j'espérais qu'il y aurait un mécanisme dans lequel je pourrais dire que Tc'est tout sauf un Action. c'est-à-dire une restriction de type négatif.

Imaginer:

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

Mais cette fonctionnalité n'existe pas pour TypeScript.

Quelqu'un connaît-il un moyen de faire fonctionner cela comme je m'y attendais?


Pour faciliter le débogage, voici un extrait de code complet:

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

Vous pouvez jouer avec cet exemple de code ici: https://codesandbox.io/s/reverent-star-m4sdb?fontsize=14

ctrlplusb
la source

Réponses:

7

C'est un problème intéressant. Le script typographique ne peut généralement pas faire grand-chose en ce qui concerne les paramètres de type génériques dans les types conditionnels. Il diffère simplement toute évaluation extendss'il constate que l'évaluation implique un paramètre de type.

Une exception s'applique si nous pouvons obtenir un script typographique pour utiliser un type spécial de relation de type, à savoir une relation d'égalité (et non une relation d'extension). Une relation d'égalité est simple à comprendre pour le compilateur, il n'est donc pas nécessaire de différer l'évaluation de type conditionnelle. Les contraintes génériques sont l'un des rares endroits du compilateur où l'égalité de type est utilisée. Regardons un exemple:

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

Lien Playground

Nous pouvons profiter de ce comportement pour identifier des types spécifiques. Maintenant, ce sera une correspondance de type exacte, pas une correspondance étendue, et les correspondances de type exact ne conviennent pas toujours. Cependant, comme il ne Actions'agit que d'une signature de fonction, les correspondances de type exact peuvent fonctionner assez bien.

Voyons si nous pouvons extraire des types qui correspondent à une signature de fonction plus simple telle que (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;
  // }

}

Lien Playground

Le type ci-dessus KeysOfIdenticalTypeest proche de ce dont nous avons besoin pour le filtrage. Pour other, le nom de la propriété est conservé. Pour le action, le nom de la propriété est effacé. Il n'y a qu'un seul problème embêtant value. Étant donné que valueest de type T, n'est pas résolu trivialement Tet (v: T) => voidn'est pas identique (et en fait il se peut qu'il ne le soit pas).

Nous pouvons toujours déterminer qu'il valueest identique à T: pour les propriétés de type T, coupez cette vérification (v: T) => voidavec never. Toute intersection avec neverest trivialement résolvable en never. On peut ensuite rajouter des propriétés de type en Tutilisant une autre vérification d'identité:

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

}

Lien Playground

La solution finale ressemble à ceci:

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

Lien Playground

REMARQUES: La limitation ici est que cela ne fonctionne qu'avec un seul type de paramètre (bien qu'il puisse éventuellement être adapté à d'autres). De plus, l'API est un peu déroutante pour tous les consommateurs, donc ce n'est peut-être pas la meilleure solution. Il peut y avoir des problèmes que je n'ai pas encore identifiés. Si vous en trouvez, faites le moi savoir 😊

Titien Cernicova-Dragomir
la source
2
J'ai l'impression que Gandalf le Blanc vient de se révéler. 🤯 TBH J'étais prêt à annuler cela en tant que limitation du compilateur. Tellement content de l'essayer. Je vous remercie! 🙇
ctrlplusb
@ctrlplusb 😂 LOL, ce commentaire a fait ma journée 😊
Titien Cernicova-Dragomir
Je voulais appliquer la prime à cette réponse, mais j'ai un grave manque de cerveau de bébé endormi et mal cliqué. Mes excuses! C'est une réponse incroyablement perspicace. Bien que de nature assez complexe. 😅 Merci beaucoup d'avoir pris le temps d'y répondre.
ctrlplusb
@ctrlplusb :( Oh bien .. gagnez en perdez :) :)
Titian Cernicova-Dragomir
2

Ce serait formidable si je pouvais exprimer que T n'est pas de type Action. Une sorte d'inverse des extensions

Exactement comme vous l'avez dit, le problème est que nous n'avons pas encore de contrainte négative. J'espère également qu'ils pourront bientôt débarquer une telle fonctionnalité. En attendant, je propose une solution de contournement comme celle-ci:

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
la source
Pas idéal, mais super de connaître une solution de contournement semi :)
ctrlplusb
1

countet valuerendra toujours le compilateur malheureux. Pour y remédier, vous pouvez essayer quelque chose comme ceci:

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

Puisque Partialle type d'utilitaire est utilisé, vous serez d'accord dans le cas où la transformméthode n'est pas présente.

Stackblitz

Lucas
la source
1
"le nombre et la valeur rendront toujours le compilateur mécontent" - j'apprécierais un peu le pourquoi ici. xx
ctrlplusb
1

En général, je le lis deux fois et je ne comprends pas vraiment ce que vous voulez réaliser. D'après ma compréhension, vous voulez omettre transformdu type qui est donné exactement transform. Pour que cela soit simple, nous devons utiliser Omit :

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

Je ne sais pas si c'est ce que vous vouliez en raison de la complexité que vous avez donnée dans les types d'utilitaires supplémentaires. J'espère que cela aide.

Maciej Sikora
la source
Merci, oui je le souhaite. Mais c'est un type d'utilitaire que j'exporte pour une consommation tierce. Je ne connais pas la forme / les propriétés de leurs objets. Je sais juste que je dois supprimer toutes les propriétés de la fonction et utiliser le résultat contre l'argument de transformation de données func.
ctrlplusb
J'ai mis à jour ma description de problème dans l'espoir qu'elle soit plus claire.
ctrlplusb
2
Le problème principal est que T peut également être de type Action car il n'est pas défini pour l'exclure. L'espoir trouvera une solution. Mais je suis à l'endroit où le décompte est ok mais T est toujours omis car c'est l'intersection avec Action
Maciej Sikora
Ce serait formidable si je pouvais exprimer que T n'est pas de type Action. Sorte d'inverse de extend.
ctrlplusb
Discussion relative: stackoverflow.com/questions/39328700/…
ctrlplusb