Problema con proprietà generiche durante la mappatura dei tipi


11

Ho una libreria che esporta un tipo di utilità simile al seguente:

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

Questo tipo di utilità consente di dichiarare una funzione che verrà eseguita come "azione". Riceve un argomento generico in base al Modelquale l'azione agirà.

L' dataargomento di "azione" viene quindi digitato con un altro tipo di utilità che esporto;

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

Il Statetipo di utilità prende sostanzialmente il Modelgenerico in entrata e quindi crea un nuovo tipo in cui tutte le proprietà di tipo Actionsono state rimosse.

Ad esempio, qui è un'implementazione di terra utente di base di quanto sopra;

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

Quanto sopra funziona molto bene. 👍

Tuttavia, c'è un caso con cui sto lottando, in particolare quando viene definita una definizione di modello generico, insieme a una funzione di fabbrica per produrre istanze del modello generico.

Per esempio;

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

Nell'esempio sopra mi aspetto che l' dataargomento venga digitato nel punto in cui l' doSomethingazione è stata rimossa e la valueproprietà generica esiste ancora. Questo comunque non è il caso: la valueproprietà è stata rimossa anche dalla nostra Stateutility.

Credo che la causa Tsia generica senza che vi siano applicate restrizioni / restringimenti del tipo, e quindi il sistema dei tipi decide che si interseca con un Actiontipo e successivamente lo rimuove dal datatipo di argomento.

C'è un modo per aggirare questa limitazione? Ho fatto qualche ricerca e spero che ci sarebbe un meccanismo in cui potrei affermare che Tè tutto tranne che per un Action. cioè una restrizione di tipo negativa.

Immaginare:

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

Ma questa funzione non esiste per TypeScript.

Qualcuno sa come posso farlo funzionare come mi aspetto?


Per facilitare il debug ecco uno snippet di codice 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;
    }
  };
}

Puoi giocare con questo esempio di codice qui: https://codesandbox.io/s/reverent-star-m4sdb?fontsize=14

Risposte:


7

Questo è un problema interessante Il dattiloscritto generalmente non può fare molto riguardo ai parametri di tipo generico nei tipi condizionali. Difende qualsiasi valutazione extendsse rileva che la valutazione comporta un parametro di tipo.

Un'eccezione si applica se possiamo ottenere dattiloscritto per utilizzare un tipo speciale di relazione di tipo, vale a dire una relazione di uguaglianza (non una relazione estesa). Una relazione di uguaglianza è semplice da comprendere per il compilatore, quindi non è necessario rinviare la valutazione del tipo condizionale. I vincoli generici sono uno dei pochi punti nel compilatore in cui viene utilizzata l'uguaglianza dei tipi. Diamo un'occhiata a un esempio:

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 al parco giochi

Possiamo sfruttare questo comportamento per identificare tipi specifici. Ora, questa sarà una corrispondenza esatta del tipo, non una corrispondenza estesa, e le corrispondenze esatte del tipo non sono sempre adatte. Tuttavia, da quandoAction è solo una firma di funzione, le corrispondenze esatte di tipi potrebbero funzionare abbastanza bene.

Vediamo se siamo in grado di estrarre tipi che corrispondono a una firma di funzione più semplice come (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 al parco giochi

Il tipo sopra KeysOfIdenticalTypeè vicino a ciò di cui abbiamo bisogno per il filtro. Per other, il nome della proprietà è conservato. Per il action, il nome della proprietà viene cancellato. C'è solo un fastidioso problema in giro value. Poiché valueè di tipo T, non è banalmente risolvibile T, e (v: T) => voidnon sono identici (e in effetti potrebbero non esserlo).

Possiamo ancora determinare che valueè identico a T: per le proprietà di tipo T, interseca questo controllo (v: T) => voidcon never. Qualsiasi intersezione con neverè banalmente risolvibile in never. Possiamo quindi aggiungere proprietà del tipo Tusando un altro controllo di 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;
  // }

}

Link al parco giochi

La soluzione finale è simile a questa:

// 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 al parco giochi

NOTE: La limitazione qui è che funziona solo con un parametro di tipo (sebbene possa eventualmente essere adattato a più). Inoltre, l'API è un po 'confusa per tutti i consumatori, quindi questa potrebbe non essere la soluzione migliore. Potrebbero esserci problemi che non ho ancora identificato. Se ne trovi qualcuno, fammi sapere 😊


2
Mi sento come se Gandalf il Bianco si fosse appena rivelato. 🤯 TBH Ero pronto a scriverlo come una limitazione del compilatore. Così entusiasta di provare questo. Grazie! 🙇
ctrlplusb

@ctrlplusb 😂 LOL, quel commento mi ha reso felice 😊
Tiziano Cernicova-Dragomir

Intendevo applicare la generosità a questa risposta, ma ho una grave mancanza di sonno, il cervello del bambino sta succedendo e si è sbagliato. Mie scuse! Questa è una risposta straordinariamente penetrante. Anche se di natura abbastanza complessa. 😅 Grazie mille per aver dedicato del tempo per rispondere.
ctrlplusb,

@ctrlplusb :( Vabbè .. vinci un po 'di perdere :)
Tiziano Cernicova-Dragomir,

2

Sarebbe bello se potessi esprimere che T non è di tipo Azione. Sorta di un inverso di estensioni

Esattamente come hai detto, il problema è che non abbiamo ancora vincoli negativi. Spero anche che possano presto ottenere tale funzionalità. In attesa, propongo una soluzione come questa:

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
}

Non è l'ideale, ma è bello sapere di una soluzione alternativa semi :)
ctrlplusb

1

counte valuerenderà sempre il compilatore infelice. Per risolverlo potresti provare qualcosa del genere:

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

Poiché Partialviene utilizzato il tipo di utilità, si andrà bene nel caso in cui il transformmetodo non sia presente.

Stackblitz


1
"il conteggio e il valore renderanno sempre insoddisfacente il compilatore" - Gradirei alcune intuizioni sul perché qui. xx
ctrlplusb

1

In genere l'ho letto due volte e non capisco perfettamente cosa vuoi ottenere. Dalla mia comprensione, vuoi omettere transformdal tipo che è dato esattamente transform. Per ottenere ciò è semplice, dobbiamo usare 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
    },
  };
}

Non sono sicuro se questo è quello che volevi a causa della complessità che hai fornito nei tipi di utilità aggiuntivi. Spero che sia d'aiuto.


Grazie, si lo desidero. Ma questo è un tipo di utility che sto esportando per il consumo di terze parti. Non conosco la forma / le proprietà dei loro oggetti. So solo che devo eliminare tutte le proprietà delle funzioni e utilizzare il risultato rispetto all'argomento transform func data.
ctrlplusb

Ho aggiornato la descrizione del mio problema nella speranza che sia più chiara.
ctrlplusb

2
Il problema principale è che T può essere anche Tipo di azione in quanto non è definito per escluderlo. La speranza troverà qualche soluzione. Ma sono nel posto in cui il conteggio è ok ma T è ancora omesso perché è un incrocio con l'Azione
Maciej Sikora

Sarebbe bello se potessi esprimere che T non è di tipo Azione. Sorta di un inverso di estensioni.
ctrlplusb

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.