-
Notifications
You must be signed in to change notification settings - Fork 12.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
3.5.1 (regression): "string cannot be used to index T" where T is Record<string, any> #31661
Comments
Here's a similar case I ran into: interface Bag {
[key: string]: number;
}
function put<T extends Bag>(a: T, k: string, v: number): void {
a[k] = v;
} Compiling that produces this:
|
This is the intended behavior but I need to write several pages of documentation for "How do index signatures in generic constraints work [if at all]" |
Confusingly, this will compile without error (though interface Bag {
[key: number]: boolean;
}
function put<T extends Bag>(a: T) {
a["x"] = "y";
} |
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 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 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 |
I'm still a little confused by that explanation. Given this dubious bit of code, in both interface Bag {
[k: string]: number;
}
function put1(bag: Bag) {
bag["z"] = 3;
}
function put2<T extends Bag>(baggish: T) {
baggish["z"] = 3;
}
const bag: Bag = {};
put1(bag);
put2(bag);
type Point = {x: number};
const point = {x: 3};
put1(point);
put2(point); |
- Required by improved soundness check introduced in TS 3.5.1 - microsoft/TypeScript#31661
- Required by improved soundness check introduced in TS 3.5.1 - microsoft/TypeScript#31661
I don't accept this "explanation". Why is there even a place for the key type in
That is the whole point!!! That's why the type is |
Also: Why would it EVER complain — in Javascript! — if I use a string as index in an object? By using In general, coming from (the more strict!) Flow, all those many errors I now have in TypeScript when I iterate over my objects (using Object.keys) that "there is no index signature" are really, really... strange. Alternatively, please document how to document generic (key-prop/value) objects when what I do is iterate over the keys. Without "any". Thus far I read the documentation to mean that |
Not sure if this helps the case: |
@patroza If that helps, and maybe in general anyway (I have not thought about wider implications), what if As an experiment I changed the definition in lib.es2015.refelect.d.ts from ( function ownKeys(target: object): PropertyKey[]; to function ownKeys<T extends object>(target: T): Array<keyof T>; and everything is PERFECT now! I had to remove the So maybe the underlying TS-lib definition can be looked and any maybe changed? |
@lll000111 yea I was also surprised that the standard library didn't implement it this way at least for |
@patroza For the reason |
@ahejlsberg makes sense, thanks for shedding light on it. We can constrain it ourselves with for example the helper I posted, when we need it. That's good enough for me. |
@felix9 The distinction becomes evident when you consider both input (parameter) and output (function result) positions. Imagine that function put1(bag: Bag): Bag {
bag["z"] = 3;
return bag;
}
function put2<T extends Bag>(baggish: T): T {
baggish["z"] = 3; // Error
return baggish;
}
const p1: Point = put1({ x: 3 }); // Error
const p2: Point = put2({ x: 3 }); Following a call to However, following a call to |
(1)
Here is the part I find highly confusing:
As I am reading the discussion above, it seems to me that If that was the case, the the example (2) function foo<T extends Record<string, any>>(obj: T) {
obj["s"] = 123;
obj["n"] = "hello";
} IMO, the code above is valid in the sense that it's ok to assign to I think a more correct definition of function foo<T extends Record<keyof T, any>>(obj: T) {
obj["s"] = 123;
obj["n"] = "hello";
} Such code is triggering the following error for me: Because function foo<T extends Record<keyof T | 's' | 'n', any>>(obj: T) {
obj["s"] = 123;
obj["n"] = "hello";
} That way the compiler can verify:
(3) function clone<T extends Record<keyof T, any>>(obj: T): T {
const objectClone = {} as T;
const ownKeys = Reflect.ownKeys(obj) as (keyof T)[];
for (const prop of ownKeys.filter(isString)) {
objectClone[prop] = obj[prop];
}
return objectClone;
} This seems to work with TypeScript v3.5, although I am surprised that Thoughts? |
That would one way to do it. However, it is very common to initialize map-like objects using object literals, and for an object literal we know the exact set of properties from which to compute an implicit index signature. So we permit the assignment for types originating in object literals.
I'd write it like this: function clone<T extends object>(obj: T): T {
const objectClone = {} as T;
const ownKeys = Reflect.ownKeys(obj) as (keyof T)[];
for (const prop of ownKeys) {
objectClone[prop] = obj[prop];
}
return objectClone;
} No need to use index signatures anywhere. And, to recap, for the reason |
Just throwing my two cents in: In general I expect So my first instinct is that |
I'm trying to understand this change, but having trouble with the current explanations. In particular, it's unclear why the change should apply specifically to a generic type @ahejlsberg, you tried to address the distinction at #31661 (comment), but it seems like that explanation has a similar problem. Your example shows that the new behavior prevents
So, again, why should the generic code be restricted more than the non-generic equivalent? I've been trying to unpack the following for a clue:
But again it's not clear how this is different from non-generic parameters -- if I pass a value to a non-generic parameter of type I think it will help a lot to see some specific examples of real-world errors this is intended to fix (if you only have time for a quick response, this would be the most helpful). So far, the compelling error cases I've seen (the "previously allowed crazy stuff" mentioned above) seem to be a result of the special behavior of Are there many errors you're seeing that don't involve that? If not, I would put forth an alternate proposal: for generic constraints, only ignore index signatures equivalent to This would hopefully make the language simpler and more consistent, and save @RyanCavanaugh from having to write several pages of documentation 😄 (would love to hear your take on this, Ryan, and/or Anders). |
Thinking about this a bit more, this still doesn't feel great in terms of consistency, though at least it would be a more targeted special case. A better, even more targeted change would be to not ignore any index signatures, but to ignore the " That would eliminate the "previously allowed crazy stuff" while preserving expected/consistent behavior everywhere else (including the code from the top of this issue). I'm curious how much existing code it would break (though it may also uncover new errors that even #30769 doesn't catch). If anyone can point me to any context/history/motivation for the " |
So having thought about this some more, particularly in light of #31102, it turns out the aforementioned "crazy stuff" is still allowed, and for the same reason even. We've only closed the loophole for generics, but this is still fine: type X = { [key: string]: number };
type Y = { pig: number, cow: number, ape: number };
let y: Y = { pig: 812, cow: 1208, ape: 128 };
let x: X = y;
x.whale = 9001; // yeah, there's no way this is fitting on the elevator.
console.log(y); As long as the above continues to work, I suspect we will never see the end of issues like #31808... edit: |
I kind of agree with this, but then again I kind of don't. The difference is that in the generic case, you have a type variable That being said, it does still look like inconsistency from an end-user point of view, and that's probably all that matters. |
@fatcerberus thanks for the input. I'm not 100% sold on the reasoning without seeing concrete examples, but either way I agree with your final point. If there are practical reasons for the change, we should focus on those. |
What about this?
function deepMerge<T1 extends Record<string, any>, T2 extends Record<string, any>>(
source: T1,
target: T2
): T1 & T2 {
Object.keys(target).forEach(key => {
if (source[key] == null) {
source[key] = target[key];
} else if (typeof source[key] === 'object') {
if (typeof target[key] === 'object') {
deepMerge(source[key], target[key]);
} else {
source[key] = target[key];
}
} else {
source[key] = target[key];
}
});
return source as any;
} |
Any update on the deepMerge type implementation? I'm also stuck on this. |
How about this one: export const deepMerge = <T extends object = object, U extends object = object>(a: T, b: U) => {
let result = Object.assign(a, b)
for (const key in a) {
if (!a[key] || (b.hasOwnProperty(key) && typeof b[(key as unknown) as keyof U] !== 'object')) continue
Object.assign(result, {
[key]: Object.assign(a[key], b[(key as unknown) as keyof U])
})
}
return result
} |
Inside deepMerge it's better to cast all to any. No need to strongly type it. We need to know the inputs and the output type, and what is inside is a black box. Compromise. |
Here is what I came up with. TypeScript Playground class Util {
public static DeepCopy<T>(target: T): T {
if (target === null) {
return target;
}
if (target instanceof Date) {
return new Date(target.getTime()) as any;
}
if (target instanceof Array) {
const cp = [] as any[];
(target as any[]).forEach((v) => { cp.push(v); });
return cp.map((n: any) => Util.DeepCopy<any>(n)) as any;
}
if (typeof target === 'object' && target !== {}) {
const cp = { ...(target as { [key: string]: any }) } as { [key: string]: any };
Object.keys(cp).forEach(k => {
cp[k] = Util.DeepCopy<any>(cp[k]);
});
return cp as T;
}
return target;
}
public static MergeDefaults<T extends object = object, U extends object = object>(defaults: U, ...opt: T[]) {
// Example: https://jsfiddle.net/6p4rzmxo/1/
let result = Util.DeepCopy(defaults);
Util.DeepMerge(result, ...opt);
return result;
}
public static IsObject(obj: any) {
return (obj && typeof obj === 'object' && !Array.isArray(obj));
}
public static DeepMergeGeneric<T extends object = object, U extends object = object>(target: T, ...sources: U[]): any {
return Util.DeepMerge(target, ...sources);
}
public static DeepMerge<T extends object = object>(target: T, ...sources: any[]): any {
if (!sources.length) {
return target;
}
const source = sources.shift();
if (Util.IsObject(target) && Util.IsObject(source)) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const el = source[key];
if (Util.IsObject(el)) {
if (!(target[(key as unknown) as keyof T])) {
Object.assign(target, { [key]: {} });
};
const newTarget = target[(key as unknown) as keyof T];
Util.DeepMerge(newTarget as Object, el);
} else {
Object.assign(target, { [key]: source[key] });
}
}
}
}
return Util.DeepMerge(target, ...sources);
}
} |
This has been already commented but still adding it here for completeness and to help devs struggling with a simpler case. Here's an example public set fields(fieldsData: T) {
(Object.keys(fieldsData) as (keyof T)[])
.forEach((fd) => {
this._fields[fd] = fieldsData[fd];
});
} |
I ran into this: export function pick<T extends Record<string, any>>(
obj: T,
...props: string[]
): Partial<T> {
return Object.entries(obj).reduce<Partial<T>>((accumulator, [key, value]) => {
if (props.includes(key)) accumulator[key] = value;
return accumulator;
}, {});
} managed fixed it through comments above: export function pick<T extends Record<string, any>>(
obj: T,
...props: string[]
): Partial<T> {
return Object.entries(obj).reduce<Partial<T>>((accumulator, [key, value]) => {
if (props.includes(key)) accumulator[key as keyof T] = value; // <- crucial as keyof T
return accumulator;
}, {});
} |
This is new as of 3.5.1, there was no error in 3.4.5.
Error message:
Obviously wrong, since
T
is defined asRecord<string, any>
.Playground Link — It will not show an error until the Playground uses the new TS 3.5.x
The text was updated successfully, but these errors were encountered: