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
