Problema com propriedades genéricas ao mapear tipos

11

Eu tenho uma biblioteca que exporta um tipo de utilitário semelhante ao seguinte:

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

Este tipo de utilitário permite declarar uma função que será executada como uma "ação". Ele recebe um argumento genérico contra o Modelqual a ação operará.

O dataargumento da "ação" é digitado com outro tipo de utilitário que eu exporto;

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

O Statetipo de utilitário basicamente pega o Modelgenérico recebido e cria um novo tipo em que todas as propriedades do tipo Actionforam removidas.

Por exemplo, aqui está uma implementação básica do usuário acima;

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

O acima está funcionando muito bem. 👍

No entanto, há um caso com o qual estou lutando, especificamente quando uma definição de modelo genérico é definida, juntamente com uma função de fábrica para produzir instâncias do modelo genérico.

Por exemplo;

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

No exemplo acima, espero que o dataargumento seja digitado onde a doSomethingação foi removida e a valuepropriedade genérica ainda exista. No entanto, esse não é o caso - a valuepropriedade também foi removida por nosso Stateutilitário.

Acredito que a causa disso é que Té genérico, sem que sejam aplicadas restrições / restrições de tipo, e, portanto, o sistema de tipos decide que se cruza com um Actiontipo e subsequentemente o remove do datatipo de argumento.

Existe uma maneira de contornar essa restrição? Eu fiz algumas pesquisas e esperava que houvesse algum mecanismo no qual eu pudesse afirmar que Texiste, exceto um Action. ou seja, uma restrição de tipo negativo.

Imagine:

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

Mas esse recurso não existe para o TypeScript.

Alguém sabe como eu poderia fazer isso funcionar como eu esperava?


Para ajudar na depuração, aqui está um trecho 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;
    }
  };
}

Você pode jogar com este exemplo de código aqui: https://codesandbox.io/s/reverent-star-m4sdb?fontsize=14

ctrlplusb
fonte

Respostas:

7

Este é um problema interessante. O texto datilografado geralmente não pode fazer muito em relação aos parâmetros de tipo genérico em tipos condicionais. Ele apenas adia qualquer avaliação extendsse descobrir que a avaliação envolve um parâmetro de tipo.

Uma exceção se aplica se conseguirmos que o texto datilografado use um tipo especial de relação de tipo, a saber, uma relação de igualdade (não uma relação estendida). Uma relação de igualdade é simples de entender para o compilador, portanto, não há necessidade de adiar a avaliação do tipo condicional. Restrições genéricas são um dos poucos lugares no compilador em que a igualdade de tipos é usada. Vejamos um exemplo:

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 para parque infantil

Podemos tirar proveito desse comportamento para identificar tipos específicos. Agora, essa será uma correspondência exata de tipo, não extensa e nem sempre é adequada. No entanto, como Actioné apenas uma assinatura de função, as correspondências exatas de tipo podem funcionar bem o suficiente.

Vamos ver se podemos extrair tipos que correspondem a uma assinatura de função mais simples, 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;
  // }

}

Link para parque infantil

O tipo acima KeysOfIdenticalTypeestá próximo do que precisamos para a filtragem. Para other, o nome da propriedade é preservado. Para o action, o nome da propriedade é apagado. Há apenas uma questão incômoda por aí value. Como valueé do tipo T, não é trivialmente solucionável Te (v: T) => voidnão é idêntico (e, de fato, pode não ser).

Ainda podemos determinar que valueé idêntico a T: para propriedades do tipo T, cruze essa verificação (v: T) => voidcom never. Qualquer interseção com neveré trivialmente resolvível para never. Em seguida, podemos adicionar novamente as propriedades do tipo Tusando outra verificação de identidade:

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 para parque infantil

A solução final é mais ou menos assim:

// 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 para parque infantil

OBSERVAÇÕES: A limitação aqui é que isso funciona apenas com um parâmetro de tipo (embora possa ser adaptado para mais). Além disso, a API é um pouco confusa para qualquer consumidor, portanto, essa pode não ser a melhor solução. Pode haver problemas que ainda não identifiquei. Se você encontrar algum, me avise 😊

Titian Cernicova-Dragomir
fonte
2
Eu sinto que Gandalf, o Branco, acabou de se revelar. 🤯 TBH, eu estava pronto para escrever isso como uma limitação do compilador. Tão feliz por tentar isso. Obrigado! C
ctrlplusb 15/11/19
@ctrlplusb 😂 LOL, que o comentário fez o meu dia 😊
Ticiano Cernicova-Dragomir
Eu pretendia aplicar a recompensa a essa resposta, mas tenho uma grave falta de sono no cérebro do bebê e foi enganada. Me desculpe! Esta é uma resposta fantasticamente perspicaz. Embora bastante complexo na natureza. 😅 Muito obrigado por reservar um tempo para responder.
Ctrlplusb
@ctrlplusb :( Oh bem .. ganhar alguns perdem um pouco :)
Ticiano Cernicova-Dragomir
2

Seria ótimo se eu pudesse expressar que T não é do tipo Action. Tipo de inversão de extensões

Exatamente como você disse, o problema é que ainda não temos restrições negativas. Espero também que eles consigam esse recurso em breve. Enquanto aguardo, proponho uma solução alternativa 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
fonte
Não é o ideal, mas é bom saber sobre uma solução alternativa :) #
2200 ctrlplusb
1

counte valuesempre deixará o compilador infeliz. Para corrigi-lo, você pode tentar algo como isto:

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

Como o Partialtipo de utilitário está sendo usado, você estará bem, caso o transformmétodo não esteja presente.

Stackblitz

Lucas
fonte
11
"contagem e valor sempre tornarão o compilador infeliz" - eu apreciaria algumas dicas sobre o porquê aqui. xx
ctrlplusb 7/11/19
1

Geralmente, li isso duas vezes e não entendo completamente o que você deseja alcançar. Pelo meu entendimento, você deseja omitir transformdo tipo que é dado exatamente transform. Para que isso seja simples, precisamos usar 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
    },
  };
}

Não tenho certeza se é isso que você queria, devido à complexidade que você forneceu nos tipos de utilitários adicionais. Espero que ajude.

Maciej Sikora
fonte
Obrigado, sim, eu desejo. Mas este é um tipo de utilitário que estou exportando para consumo de terceiros. Não sei a forma / propriedades de seus objetos. Só sei que preciso retirar todas as propriedades da função e utilizar o resultado no argumento transformar dados de função.
Ctrlplusb 07/11/19
Atualizei a descrição do meu problema na esperança de torná-lo mais claro.
Ctrlplusb 07/11/19
2
O principal problema é que T também pode ser do tipo Ação, pois não está definido para excluí-lo. A esperança encontrará alguma solução. Mas eu estou no lugar onde a contagem é ok, mas T ainda é omitido porque é interseção com Ação
Maciej Sikora
Seria ótimo se eu pudesse expressar que T não é do tipo Action. Tipo de um inverso de extensões.
Ctrlplusb 9/11/19
Discussão relativa: stackoverflow.com/questions/39328700/…
ctrlplusb