Home > Software design >  Typescript: Add dynamic property with type safety
Typescript: Add dynamic property with type safety

Time:09-16

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!

Playground link to code

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.

Playground

  • Related