1

Lets say I want to have a common type for dynamicly created objects, which always have a name property and some numerical properties. Now I would like to pass such an object around and access the numerical properties dynamically but guarded.

I was thinking of something like this:

type MyObject<T extends string = string> = {[P in T]:number;} & {name:string};
type MyProperty<T extends MyObject> = Exclude<keyof T, "name">;

function processValue<T extends MyObject>(enhanced:T, key:MyProperty<T>) {
    enhanced[key]; // always a number
}

const o:MyObject<"speed" | "price"> = {name:"car", speed:100, price:200};
processValue(o, "speed");

however typescript throws compile error

Argument of type 'MyObject<"speed" | "price">' is not assignable to parameter of type 'MyObject<string>'.
  Type 'MyObject<"speed" | "price">' is not assignable to type '{ [x: string]: number; }'.
    Property 'name' is incompatible with index signature.
      Type 'string' is not assignable to type 'number'.

Is there a way to modify MyObject or MyProperty so I can keep using processValue() function as is?

Yoz
  • 707
  • 6
  • 20
  • 1
    Does [this approach](https://tsplay.dev/w8YGEN) meet your needs? If so I could write an answer explaining; if not, what am I missing? – jcalz May 31 '23 at 14:14
  • That looks very promising! – Yoz May 31 '23 at 17:23
  • Does that mean it's likely to suffice and I should write up an answer? Or do you still need to test it before you're sure and I should wait (so I don't spend time describing something I'll need to change)? – jcalz May 31 '23 at 17:27
  • I have tested it and have your solution in use. Thanks – Yoz Jun 01 '23 at 08:51

1 Answers1

1

TypeScript doesn't make it easy to describe an object where all unknown properties are required to have a type incompatible with those of the known properties. This is the topic of microsoft/TypeScript#17687, and there are currently only workarounds with various drawbacks. Also see How to define Typescript type as a dictionary of strings but with one numeric "id" property.

To make your code compile without error, you could write it this way:

type MyObject<K extends PropertyKey> =
  { [P in Exclude<K, "name">]: number; } & { name: string };

type MyProperty<T extends MyObject<keyof T>> =
  Exclude<keyof T, "name">;

function processValue<T extends MyObject<keyof T>>(enhanced: T, key: MyProperty<T>) {
  enhanced[key]; // always a number
}

The original didn't work because MyObject defaulted to MyObject<string>, which was equivalent to {[k: string]: number} & {name: string}, an impossible type to supply, since the name property would need to be both a number and a string. Such an intersection type is one of those workarounds, but it's not a good fit here.

The fix is to forgo referring to a default T extends MyObject, and use a recursive generic constraint T extends MyObject<keyof T>. Or, presumably, MyObject<Exclude<keyof T, "name">. But it's easier to just modify MyObject so that it automatically excludes "name", as shown above.

Now everything works as desired:

const o: MyObject<"speed" | "price"> = { name: "car", speed: 100, price: 200 };
processValue(o, "speed"); // okay

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360