Reputation: 5907
I have strict mode on in TypeScript and am having issues setting an index on an object even through I have set up a string indexer on its type.
Disabling noImplicitAny fixes it, but I'd rather not.
I've studied this answer closely but haven't had success with the suggestions.
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]) => {
item[k] = v; // Error: Type 'string' cannot be used to index type 'T'.ts(2536)
});
}
}
let d = { name: 'fido' } as dog;
let c = { lives: 9 } as cat;
setLayerProps(d, { name: 'jim' })
setLayerProps(c, { lives: --c.lives })
Can anyone see what I'm doing wrong?
Thanks!
Upvotes: 5
Views: 7678
Reputation: 1028
This is an effect of #30769 and a known breaking change. We previously allowed crazy stuff like this with no errors:
function foo<T extends Record<string, any>>(obj: T) {
obj["s"] = 123;
obj["n"] = "hello";
}
let z = { n: 1, s: "abc" };
foo(z);
foo([1, 2, 3]);
foo(new Error("wat"));
In general, the constraint Record<string, XXX> doesn't actually ensure that an argument has a string index signature, it merely ensures that the properties of the argument are assignable to type XXX. So, in the example above you could effectively pass any object and the function could write to any property without any checks.
In 3.5 we enforce that you can only write to an index signature element when we know that the object in question has an index signature. So, you need a small change to the clone function:
function clone<T extends Record<string, any>>(obj: T): T {
const objectClone = {} as Record<string, any>;
for (const prop of Reflect.ownKeys(obj).filter(isString)) {
objectClone[prop] = obj[prop];
}
return objectClone as T;
}
With this change we now know that objectClone has a string index signature.
So your code should be
function setLayerProps<T extends dog | cat>(
item: T,
props: Partial<T>
) {
if (item) {
Object.entries(props).forEach(([k, v]) => {
(item as dog | cat)[k] = v;
});
}
}
Reference: https://github.com/microsoft/TypeScript/issues/31661#issuecomment-497138929
Upvotes: 8
Reputation: 3412
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
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 }
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
name
key, a string value is expectedThe 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.
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
.
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)
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.
Upvotes: 5