Problem z właściwościami ogólnymi podczas mapowania typów

11

Mam bibliotekę, która eksportuje typ narzędzia podobny do następującego:

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

Ten typ narzędzia pozwala zadeklarować funkcję, która będzie działać jako „akcja”. Otrzymuje ogólny argument, Modelże akcja będzie działać.

dataArgument „akcji” jest następnie wpisywane z innego typu użytkowego że eksport;

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

Typ Statenarzędzia zasadniczo przyjmuje przychodzący Modelrodzajowy, a następnie tworzy nowy typ, w którym wszystkie właściwości tego typu Actionzostały usunięte.

Na przykład tutaj jest podstawowa implementacja powyższego gruntu użytkownika;

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

Powyższe działa bardzo dobrze. 👍

Jest jednak przypadek, z którym walczę, szczególnie gdy zdefiniowana jest ogólna definicja modelu, wraz z funkcją fabryczną do tworzenia instancji modelu ogólnego.

Na przykład;

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

W powyższym przykładzie oczekuję, że dataargument zostanie wpisany w miejscu, w którym doSomethingakcja została usunięta, a valuewłaściwość ogólna nadal istnieje. Tak jednak nie jest - valuewłaściwość została również usunięta przez nasze Statenarzędzie.

Uważam, że przyczyną tego jest to, że Tnie ma żadnych ograniczeń / zawężeń typu, a zatem system typów decyduje, że przecina się z Actiontypem, a następnie usuwa go z datatypu argumentu.

Czy istnieje sposób na obejście tego ograniczenia? Zrobiłem rozeznanie i miał nadzieję, że będzie jakiś mechanizm, w którym mogę stwierdzić, że Tjest dowolny z wyjątkiem dla Action. tj. ograniczenie typu ujemnego.

Wyobrażać sobie:

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

Ale ta funkcja nie istnieje dla TypeScript.

Czy ktoś wie, jak mogę sprawić, by działało tak, jak się tego spodziewam?


Aby ułatwić debugowanie, tutaj jest pełny fragment kodu:

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

Możesz grać z tym przykładem kodu tutaj: https://codesandbox.io/s/reverent-star-m4sdb?fontsize=14

ctrlplusb
źródło

Odpowiedzi:

7

To interesujący problem. Maszynopis ogólnie nie może wiele zrobić w odniesieniu do ogólnych parametrów typów w typach warunkowych. Po prostu odracza wszelką ocenę, extendsczy stwierdzi, że ocena obejmuje parametr typu.

Wyjątek dotyczy sytuacji, w której możemy użyć maszynopisu do użycia specjalnego rodzaju relacji typu, a mianowicie relacji równości (a nie relacji rozszerzającej). Relacja równości jest łatwa do zrozumienia dla kompilatora, więc nie ma potrzeby odkładania oceny typu warunkowego. Ograniczenia ogólne są jednym z niewielu miejsc w kompilatorze, w których stosowana jest równość typów. Spójrzmy na przykład:

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

Link do placu zabaw

Możemy wykorzystać to zachowanie do identyfikacji określonych typów. Teraz będzie to dopasowanie typu dokładnego, a nie dopasowanie rozszerzenia, a dopasowanie typu dokładnego nie zawsze jest odpowiednie. Ponieważ jednak Actionjest to tylko podpis funkcji, dokładne dopasowanie typu może działać wystarczająco dobrze.

Zobaczmy, czy możemy wyodrębnić typy pasujące do prostszej sygnatury funkcji, takie jak (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;
  // }

}

Link do placu zabaw

Powyższy typ KeysOfIdenticalTypejest zbliżony do potrzebnych do filtrowania. Dla othernazwa właściwość jest zachowana. W przypadku actionnazwa właściwości jest usuwana. Jest tylko jeden nieznośny problem value. Ponieważ valuejest typu T, nie można go w prosty sposób rozwiązać Ti (v: T) => voidnie są one identyczne (a w rzeczywistości mogą nie być).

Nadal możemy stwierdzić, że valuejest identyczna T: dla właściwości typu T, przecinają ten czek (v: T) => voidz never. Każde skrzyżowanie z neverjest trywialnie możliwe do rozwiązania never. Następnie możemy dodać właściwości typu back Tza pomocą innego sprawdzenia tożsamości:

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

}

Link do placu zabaw

Ostateczne rozwiązanie wygląda mniej więcej tak:

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

Link do placu zabaw

UWAGI: Ograniczeniem jest to, że działa to tylko z jednym parametrem typu (chociaż można go ewentualnie dostosować do większej liczby). Ponadto interfejs API jest nieco mylący dla każdego konsumenta, więc może to nie być najlepsze rozwiązanie. Mogą występować problemy, których jeszcze nie zidentyfikowałem. Jeśli znajdziesz jakieś, daj mi znać 😊

Titian Cernicova-Dragomir
źródło
2
Mam wrażenie, że Gandalf Biały właśnie się ujawnił. 🤯 TBH Byłem gotów odpisać to jako ograniczenie kompilatora. Więc bardzo się starałem to wypróbować. Dziękuję Ci! 🙇
ctrlplusb
@ctrlplusb 😂 LOL, ten komentarz sprawił, że mój dzień 😊
Titian Cernicova-Dragomir
Chciałem zastosować nagrodę za tę odpowiedź, ale mam poważny brak snu dziecka mózgowego i źle wyliczyłem. Przepraszam! To fantastycznie wnikliwa odpowiedź. Chociaż dość złożony charakter. 😅 Dziękuję bardzo za poświęcenie czasu na udzielenie odpowiedzi.
ctrlplusb
@ctrlplusb :( No cóż ... wygraj trochę przegrać :)
Titian Cernicova-Dragomir
2

Byłoby wspaniale, gdybym mógł wyrazić, że T nie jest typu Action. Coś w rodzaju odwrotności rozszerzeń

Dokładnie tak, jak powiedziałeś, problem polega na tym, że nie mamy jeszcze negatywnych ograniczeń. Mam również nadzieję, że wkrótce udostępnią taką funkcję. Podczas oczekiwania proponuję takie obejście:

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
źródło
Nie jest to idealne, ale dobrze wiedzieć o częściowym
obejściu
1

counti valuezawsze sprawi, że kompilator będzie nieszczęśliwy. Aby to naprawić, możesz spróbować czegoś takiego:

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

Ponieważ Partialużywany jest typ narzędzia, wszystko będzie w porządku, jeśli transformmetoda nie jest dostępna.

Stackblitz

Lucas
źródło
1
„Liczenie i wartość zawsze powodują, że kompilator jest nieszczęśliwy” - byłbym wdzięczny za pewien wgląd w to, dlaczego tutaj. xx
ctrlplusb
1

Ogólnie czytam to dwa razy i nie do końca rozumiem, co chcesz osiągnąć. Z mojego rozumienia chcesz pominąć transformtyp, który jest podany dokładnie transform. Aby to osiągnąć, musimy użyć Pomiń :

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

Nie jestem pewien, czy tego właśnie chciałeś ze względu na dość złożoną charakterystykę dodatkowych typów narzędzi. Mam nadzieję, że to pomoże.

Maciej Sikora
źródło
Dziękuję, tak, życzę. Ale jest to typ narzędzia, które eksportuję do użytku zewnętrznego. Nie znam kształtu / właściwości ich obiektów. Wiem tylko, że muszę usunąć wszystkie właściwości funkcji i wykorzystać wynik w argumencie danych transformacji func.
ctrlplusb
Zaktualizowałem opis problemu w nadziei, że stanie się bardziej przejrzysty.
ctrlplusb
2
Głównym problemem jest to, że T może być również typem akcji, ponieważ nie jest zdefiniowany jako wykluczenie. Nadzieja znajdzie jakieś rozwiązanie. Ale jestem w miejscu, w którym liczenie jest w porządku, ale T nadal jest pomijane, ponieważ jest to skrzyżowanie z Action
Maciej Sikora
Byłoby wspaniale, gdybym mógł wyrazić, że T nie jest typu Action. Coś w rodzaju odwrotności rozszerzeń.
ctrlplusb
Względna dyskusja: stackoverflow.com/questions/39328700/…
ctrlplusb