I have a function that adds the property imageUrl
to an object based on a fileProperty
param:
public async appendImageUrl<T>(
value: T,
fileProperty: keyof T,
): Promise<{ imageUrl: string } & T> {
const imageUrl = ...
return {
...value,
imageUrl,
};
}
Example: appendImageUrl({a: '...', imageId: '...'})
will return {a: '...', imageId: '...', imageUrl: '...'}
.
Now my question: I want to provide the name of the "target property" (until now imageUrl
) dynamically. I tried the following:
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,
};
}
This gives me the following error:
Type 'T & { [x: string]: string; }' is not assignable to type 'R'. 'R' could be instantiated with an arbitrary type which could be unrelated to 'T & { [x: string]: string; }'.ts(2322)
I do understand the problem but couldn't find solution yet.
CodePudding user response:
This is mostly a known limitation or bug in TypeScript; see microsoft/TypeScript#13948. If you use a computed property in an object literal and that property name isn't known to the compiler to be a single string literal type, then the type of the key is widened all the way to string
and you get a string index signature. Until and unless that's fixed you could work around it with a type assertion, or if you're going to do that a lot, wrap the type assertion in a helper function like this:
function kv<K extends PropertyKey, V>(key: K, value: V) {
return { [key]: value } as { [P in K]: { [Q in P]: V } }[K];
}
The kv()
function takes a key of type K
and a value of type V
and returns an object whose type has a property with that key type and that value type. There are all kinds of caveats with this sort of object creation. The "obvious" return type would be Record<K, V>
(equivalent to {[P in K]: V}
), but if K
is a union type then that results in the wrong output. If you call kv(Math.random()<0.5?"a":"b", 123)
you want something like {a: number} | {b: number}
and not {a: number; b: number}
, right? If so, then we need the more complicated { [P in K]: { [Q in P]: V } }[K]
type. Let's just test it quickly:
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}
Okay, looks good. Now we can use this helper in your method:
public async appendPresignedUrl<T, K extends PropertyKey>(
value: T,
targetProperty: K,
) {
const presignedUrl = "abc"
return {
...value,
...kv(targetProperty, presignedUrl),
};
}
The type of that method is
/* (method) Foo.appendPresignedUrl<T, K extends PropertyKey>(
value: T, targetProperty: K): Promise<T & {
[P in K]: { [Q in P]: string; }; }[K]> */
Note that I didn't add an extra generic type parameter R
there; it would be superfluous to do so, and then the compiler would be rightly concerned that maybe R
isn't the type you think it is, since callers choose generic type parameters and not implementers. Instead of R
you just need that T & {[P in K]: {[Q in P]: string}}[K]
type directly.
Let's test that it works as desired:
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; })> */
Looks good!
CodePudding user response:
You actually don't want R
since that means someone could pass the wrong type to it explicitly:
appendPresignedUrl<{ foo: "" }, "bar", never>(...); // clearly wrong, because this shouldn't return `never`.
That's why TypeSccript is warning you about this.
Instead, you get rid of R
entirely:
appendPresignedUrl<T, K extends PropertyKey>(
value: T,
fileProperty: keyof T,
targetProperty: K,
): Promise<T & Record<K, string>>
K
is also supposed to be any key, instead of keyof R
.
However, you will notice that we get an error when we try to return now, and that is because TypeScript for whatever reason makes it type { [x: string]: string }
.
I don't know any way around it, so the simplest method I have found is just using an assertion with as
.