Problem mit generischen Eigenschaften bei der Typzuordnung


11

Ich habe eine Bibliothek, die einen Dienstprogramm-Typ ähnlich dem folgenden exportiert:

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

Mit diesem Dienstprogramm können Sie eine Funktion deklarieren, die als "Aktion" ausgeführt wird. Es erhält ein allgemeines Argument dafür, Modeldass die Aktion gegen sie vorgehen wird.

Das dataArgument der "Aktion" wird dann mit einem anderen Dienstprogrammtyp eingegeben, den ich exportiere.

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

Der StateDienstprogrammtyp verwendet im Wesentlichen das eingehende ModelGenerikum und erstellt dann einen neuen Typ, bei dem alle Eigenschaften vom Typ Actionentfernt wurden.

Zum Beispiel ist hier eine grundlegende Benutzerland-Implementierung des Obigen;

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

Das obige funktioniert sehr gut. 👍

Es gibt jedoch einen Fall, mit dem ich zu kämpfen habe, insbesondere wenn eine generische Modelldefinition definiert ist, zusammen mit einer Factory-Funktion, um Instanzen des generischen Modells zu erzeugen.

Zum Beispiel;

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

Im obigen Beispiel erwarte ich, dass das dataArgument dort eingegeben wird, wo die doSomethingAktion entfernt wurde und die generische valueEigenschaft noch vorhanden ist. Dies ist jedoch nicht der Fall - die valueEigenschaft wurde auch von unserem StateDienstprogramm entfernt.

Ich glaube, die Ursache dafür ist, dass Tes generisch ist, ohne dass Typeinschränkungen / -einengungen angewendet werden, und daher entscheidet das Typsystem, dass es sich mit einem ActionTyp überschneidet, und entfernt ihn anschließend aus dem dataArgumenttyp.

Gibt es eine Möglichkeit, diese Einschränkung zu umgehen? Ich habe einige Nachforschungen angestellt und gehofft, dass es einen Mechanismus geben würde, in dem ich feststellen könnte, dass Tes einen außer einem gibt Action. dh eine negative Typbeschränkung.

Vorstellen:

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

Diese Funktion ist für TypeScript jedoch nicht vorhanden.

Kennt jemand einen Weg, wie ich das so machen kann, wie ich es erwartet habe?


Um das Debuggen zu erleichtern, finden Sie hier ein vollständiges Code-Snippet:

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

Sie können mit diesem Codebeispiel hier spielen: https://codesandbox.io/s/reverent-star-m4sdb?fontsize=14

Antworten:


7

Dies ist ein interessantes Problem. Typoskript kann im Allgemeinen nicht viel in Bezug auf generische Typparameter in bedingten Typen tun. Es verschiebt nur jede Bewertung vonextends wenn festgestellt wird, dass die Bewertung einen Typparameter umfasst.

Eine Ausnahme gilt, wenn wir Typoskript erhalten können, um eine spezielle Art von Typrelation zu verwenden, nämlich eine Gleichheitsrelation (keine erweiterte Relation). Eine Gleichheitsrelation ist für den Compiler einfach zu verstehen, sodass die Bewertung des bedingten Typs nicht verschoben werden muss. Generische Einschränkungen sind eine der wenigen Stellen im Compiler, an denen die Typgleichheit verwendet wird. Schauen wir uns ein Beispiel an:

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

Spielplatz Link

Wir können dieses Verhalten nutzen, um bestimmte Typen zu identifizieren. Dies ist nun eine exakte Typübereinstimmung, keine erweiterte Übereinstimmung, und genaue Typübereinstimmungen sind nicht immer geeignet. Da jedochAction es sich jedoch nur um eine Funktionssignatur handelt, funktionieren genaue Typübereinstimmungen möglicherweise gut genug.

Mal sehen, ob wir Typen extrahieren können, die einer einfacheren Funktionssignatur entsprechen, wie zum Beispiel (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;
  // }

}

Spielplatz Link

Der obige Typ entspricht KeysOfIdenticalTypein etwa dem, was wir zum Filtern benötigen. Denn otherder Eigenschaftsname bleibt erhalten. Für die actionwird der Eigenschaftsname gelöscht. Es gibt nur ein lästiges Problem value. Da valuees sich um einen Typ handelt T, ist dies nicht trivial auflösbar Tund (v: T) => voidnicht identisch (und möglicherweise auch nicht).

Wir können immer noch feststellen, dass dies valueidentisch ist mit T: Für Eigenschaften vom Typ Tüberschneiden Sie diese Prüfung (v: T) => voidmit never. Jeder Schnittpunkt mit neverist trivial auflösbar never. Wir können dann Eigenschaften des Typs Tmithilfe einer anderen Identitätsprüfung wieder hinzufügen :

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

}

Spielplatz Link

Die endgültige Lösung sieht ungefähr so ​​aus:

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

Spielplatz Link

HINWEISE: Die Einschränkung hierbei ist, dass dies nur mit einem Typparameter funktioniert (obwohl es möglicherweise an mehrere angepasst werden kann). Außerdem ist die API für alle Verbraucher etwas verwirrend, sodass dies möglicherweise nicht die beste Lösung ist. Möglicherweise gibt es Probleme, die ich noch nicht identifiziert habe. Wenn Sie welche finden, lassen Sie es mich wissen 😊


2
Ich fühle mich wie Gandalf der Weiße sich gerade offenbart hat. 🤯 TBH Ich war bereit, dies als Compiler-Einschränkung abzuschreiben. So begeistert, dies auszuprobieren. Vielen Dank! 🙇
ctrlplusb

@ctrlplusb 😂 LOL, dieser Kommentar machte meinen Tag 😊
Tizian Cernicova-Dragomir

Ich wollte das Kopfgeld auf diese Antwort anwenden, aber ich habe einen schweren Schlafmangel, Babyhirn, das weitergeht und falsch geklickt hat. Entschuldigen Sie! Dies ist eine fantastisch aufschlussreiche Antwort. Obwohl ziemlich komplex in der Natur. 😅 Vielen Dank, dass Sie sich die Zeit genommen haben, darauf zu antworten.
Ctrlplusb

@ctrlplusb :( Na ja .. gewinnen einige verlieren einige :)
Tizian Cernicova-Dragomir

2

Es wäre großartig, wenn ich ausdrücken könnte, dass T nicht vom Typ Aktion ist. Eine Art Inverse von erstreckt sich

Genau wie Sie sagten, ist das Problem, dass wir noch keine negativen Einschränkungen haben. Ich hoffe auch, dass sie solche Funktion bald landen können. Während des Wartens schlage ich eine Problemumgehung wie die folgende vor:

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
}

Nicht ideal, aber großartig, um eine
halbe Problemumgehung

1

countund valuewird den Compiler immer unglücklich machen. Um das Problem zu beheben, können Sie Folgendes versuchen:

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

Da der PartialDienstprogrammtyp verwendet wird, sind Sie in Ordnung, wenn die transformMethode nicht vorhanden ist.

Stackblitz


1
"Anzahl und Wert werden den Compiler immer unglücklich machen" - ich würde mich über einen Einblick in das Warum hier freuen. xx
ctrlplusb

1

Im Allgemeinen lese ich das zweimal und verstehe nicht ganz, was Sie erreichen wollen. Nach meinem Verständnis möchten Sie transformden Typ weglassen, der genau angegeben ist transform. Um dies zu erreichen, müssen wir Omit verwenden :

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

Ich bin mir nicht sicher, ob dies das ist, was Sie wollten, da Sie in den zusätzlichen Dienstprogrammtypen eine gewisse Komplexität angegeben haben. Ich hoffe es hilft.


Danke, ja, ich wünschte. Dies ist jedoch ein Dienstprogramm, das ich für den Verbrauch durch Dritte exportiere. Ich kenne die Form / Eigenschaften ihrer Objekte nicht. Ich weiß nur, dass ich alle Funktionseigenschaften entfernen und das Ergebnis für das Argument transform func data verwenden muss.
Ctrlplusb

Ich habe meine Problembeschreibung aktualisiert, in der Hoffnung, dass sie klarer wird.
Ctrlplusb

2
Das Hauptproblem ist, dass T auch ein Aktionstyp sein kann, da es nicht definiert ist, um es auszuschließen. Hoffnung wird eine Lösung finden. Aber ich bin an dem Ort, an dem die Zählung in Ordnung ist, aber T wird immer noch weggelassen, weil es Schnittpunkt mit Action ist
Maciej Sikora

Es wäre großartig, wenn ich ausdrücken könnte, dass T nicht vom Typ Aktion ist. Eine Art Inverse von erstreckt sich.
Ctrlplusb

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.