Pesquisa de site

Typescript: Adicionar propriedade dinâmica com segurança de tipo


Eu tenho uma função que adiciona a propriedade imageUrl a um objeto baseado em um parâmetro fileProperty:

public async appendImageUrl<T>(
    value: T,
    fileProperty: keyof T,
): Promise<{ imageUrl: string } & T> {
    const imageUrl = ...
    return {
        ...value,
        imageUrl,
    };
}

Exemplo: appendImageUrl({a: '...', imageId: '...'}) irá retornar {a: '...', imageId: '...', imageUrl: '...'}.

Agora minha pergunta: quero fornecer o nome da "propriedade alvo" (até agora imageUrl) dinamicamente. Eu tentei o seguinte:

public async appendPresignedUrl<T, K extends keyof R, R extends T & Record<K, string>>(
    value: T,
    fileProperty: keyof T,
    targetProperty: K,
): Promise<R> {
    const presignedUrl = ...
   
    return {
        ...value,
        [targetProperty]: presignedUrl,
    };
}

Isso me dá o seguinte erro:

O tipo 'T & { [x: string]: string; }' não é atribuível ao tipo 'R'. 'R' poderia ser instanciado com um tipo arbitrário que poderia não ter relação com 'T & { [x: string]: string; }'.ts(2322)

Eu entendo o problema, mas ainda não consegui encontrar uma solução.

Resposta #1

Essa é, na maior parte, uma limitação ou bug conhecido no TypeScript; veja microsoft/TypeScript#13948. Se você usar uma propriedade computada em um objeto literal e o nome dessa propriedade não for conhecido pelo compilador como um tipo literal de string único, então o tipo da chave é ampliado até string e você obtém uma assinatura de índice de string. Até que isso seja corrigido, você poderia contornar com uma asserção de tipo, ou se for fazer isso com frequência, envolva a asserção de tipo em uma função auxiliar assim:

function kv<K extends PropertyKey, V>(key: K, value: V) {
  return { [key]: value } as { [P in K]: { [Q in P]: V } }[K];
}

A função kv() recebe uma chave do tipo K e um valor do tipo V e retorna um objeto cujo tipo possui uma propriedade com aquele tipo de chave e aquele tipo de valor. Há todos os tipos de ressalvas com esse tipo de criação de objeto. O tipo de retorno "óbvio" seria Record<K, V> (equivalente a {[P in K]: V}), mas se K for um tipo de união, isso resulta em saída incorreta. Se você chamar kv(Math.random()<0.5?"a":"b", 123), você quer algo como {a: number} | {b: number} e não {a: number; b: number}, certo? Se for o caso, então precisamos do tipo mais complicado { [P in K]: { [Q in P]: V } }[K]. Vamos apenas testar rapidamente:

const obj1 = kv("a", 123);
// const obj1: {a: number}
const obj2 = kv(Math.random() < 0.5 ? "a" : "b", 123);
// const obj2: {a: number} | {b: number}

Ok, parece bom. Agora podemos usar este auxiliar no seu método:

  public async appendPresignedUrl<T, K extends PropertyKey>(
    value: T,
    targetProperty: K,
  ) {
    const presignedUrl = "abc"

    return {
      ...value,
      ...kv(targetProperty, presignedUrl),
    };
  }

O tipo desse método é

/* (method) Foo.appendPresignedUrl<T, K extends PropertyKey>(
    value: T, targetProperty: K): Promise<T & { 
      [P in K]: { [Q in P]: string; }; }[K]> */

Observe que eu não adicionei um parâmetro de tipo genérico extra R ali; seria supérfluo fazê-lo, e então o compilador ficaria corretamente preocupado que talvez R não seja o tipo que você pensa, já que quem escolhe os parâmetros de tipo genérico são os chamadores e não os implementadores. Em vez de R, você só precisa daquele tipo T & {[P in K]: {[Q in P]: string}}[K] diretamente.

Vamos testar se funciona como desejado:

const f = new Foo().appendPresignedUrl({ a: 123 }, "hello");
/* const f: Promise<{ a: number; } & { hello: string; }> */

const g = new Foo().appendPresignedUrl({ a: 123 }, 
  Math.random() < 0.5 ? "hello" : "goodbye");
/* const g: Promise<{ a: number; } & (
    { hello: string; } | { goodbye: string; })> */

Parece bom!

Link do playground para o código

Resposta #2

Na verdade, você não quer R já que isso significa que alguém poderia passar explicitamente o tipo errado para ele:

appendPresignedUrl<{ foo: "" }, "bar", never>(...); // clearly wrong, because this shouldn't return `never`.

É por isso que o TypeScript está avisando você sobre isso.

Em vez disso, você elimina completamente R:

appendPresignedUrl<T, K extends PropertyKey>(
    value: T,
    fileProperty: keyof T,
    targetProperty: K,
): Promise<T & Record<K, string>>

K também deve ser qualquer chave, em vez de keyof R. No entanto, você vai perceber que recebemos um erro quando tentamos retornar agora, e isso acontece porque o TypeScript, por algum motivo, define o tipo como { [x: string]: string }.

Não conheço nenhuma maneira de contornar isso, então o método mais simples que encontrei é apenas usar uma asserção com as.

Parque infantil