Disclaimer: this is not really an answer but the result of my wandering around this topic. For the sake of clarity, I am writing an answer.
TL;DR: Object keys type is string | number
, not string
Making it work
I used the TypeScript playground and ended up with something which works under TypeScript 4.0.2:
type stringKeyed = { [key: string]: any };
type dog = stringKeyed & { name: string };
type cat = stringKeyed & { lives: number };
function setLayerProps<T extends dog | cat>(item: T, props: Partial<T>) {
if (item) {
Object.entries(props).forEach(([k, v]) => {
// With an explicit key casting, TypeScript does not complain
const key = k as keyof T;
item[key] = v;
});
}
}
let d = { name: "fido" } as dog;
let c = { lives: 9 } as cat;
setLayerProps(d, { name: "jim" });
setLayerProps(c, { lives: --c.lives });
console.log(d); // [LOG]: { "name": "jim" }
console.log(c); // [LOG]: { "lives": 8 }
Why does it work?
Intersection type is...not an intersection
According to TypeScript documentation:
An intersection type combines multiple types into one. This allows you to add together existing types to get a single type that has all the features you need.
Damn, I wished I paid more attention to those two lines before.
I got fooled by the wording intersection and thought that dog
type would only allow one key (name
) but this is not the case. According to the Intersection types in TypeScript article in codingblast, intersection type makes the child type inheriting all properties from the intersected types.
In other words:
type stringKeyed = { [key: string]: any };
type dog = stringKeyed & { name: string };
means that
- For the
name
key, a string value is expected
- For any other key, any value is allowed
The following declaration is then correct:
let d: dog = {name: 'fido' };
d.something = else;
d.age = 3;
At this stage, the only conclusion we are tempted to draw is all dog
keys are string only. Well...not exactly.
JavaScript...does not know the different between a number key and a string key T_T
To quote this excellent StackOverflow answer:
As defined in the Release Notes of TypeScript 2.9, if you keyof an interface with a string index signature it returns a union of string and number
And the TypeScript documentation mentions:
If the type has a string or number index signature, keyof will return those types instead:
type Mapish = { [k: string]: boolean };
type M = keyof Mapish;
// ^ = type M = string | number
To convince myself, I tried the following:
type keys = keyof stringKeyed;
type dogKeys = keyof dog;
const keyAsStr: dogKeys = "this is OK";
const keyAsNum: dogKeys = 42;
Both keys
and dogKeys
resolve to string | number
.
string !== string | number
Now the reason why the error Error: Type 'string' cannot be used to index type 'T'.ts(2536) is raised is simply because a key type can never be string
only. The "minimal" version is string | number
, hence the typing error. Interestingly, when trying to get the T
key type in the setLayerProps
, hovering the type does not immediately display the type. So I tried with
// Inside the setLayerProps function
type tKey = keyof T;
const asStr: tKey = "43";
const asNum: tKey = 43;
const asObj: tKey = { a: "a" };
And I ended up with the following error:
Type '{ a: string; }' is not assignable to type 'keyof T'.
Type '{ a: string; }' is not assignable to type 'string | number'.
Type '{ a: string; }' is not assignable to type 'number'.
So TypeScript were indeed expecting a string | number
.
For some reason I don't understand yet, this does not work:
const key = k as string | number;
As it leads to errors:
Type 'number' cannot be used to index type 'T'.(2536)
Type 'string' cannot be used to index type 'T'.(2536)
Why explicit cast is safe
In the setLayerProps
function, we know that item
key is either a string
either a number
. So explicitely casting
const key = k as keyof T;
means I am saying that a string is a string or a number, which, upon my belief, is always true.