Why can I index by string to get a property value but not to set it?
I was trying to solve this other question's problem which involves iterating through two objects of the same unknown type and adding together their number-typed properties without type assertions (if possible).
I got close (I thought I had it down to one type assertion) but I'm flummoxed by something I ran into: Why in the following can I use key
to index result
and currentValue
when getting the value of a property, but not when setting the value of a property?
const a = {x:1, y:1, z:1};
const b = {x:2, y:2, z:2};
function reduceObjects<T extends {[key: string]: any}>(currentValue: T, previousValue: T): T {
const result = Object.assign({}, previousValue) as T;
for (const key of Object.keys(result)) {
const prev = result[key];
// ^^^^^^^^^^^−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− why is this okay...
const curr = currentValue[key];
if (typeof prev === "number" && typeof curr === "number") {
result[key] = prev + curr;
// ^^^^^^^^^^^−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− ...but this isn't?
// "Type 'string' cannot be used to index type 'T'.(2536)"
} else if (typeof prev === "string" && typeof curr === "string") {
result[key] = prev + curr;
// ^^^^^^^^^^^ (same error here of course)
}
}
return result;
}
const c = reduceObjects(a, b);
Playground link
My question is why is the error there. I'm not really trying to fix it (though if you can fix the above — probably with a different approach entirely — I do recommend you post an answer to the other question 😃). I want to understand why it matters whether I'm getting or setting the property.
Titian Cernicova Dragomir pointed out that this changed between v3.3 (where it worked) and v3.5 onward (where it stopped working).
I'm feeling as a cheater because I used @aleksxor no-type-safe-way
link. Sorry for that, I just thought that it worth some explanation.
I believe this argument is pretty good:
This is not an error.
T = { hello : "bye" }
Now, your assignment,
map["hello"] = "hi there"
Is unsound
Consider the following example:
let index: { [key: string]: any } = {}
let immutable = {
a: 'a'
} as const
let record: Record<'a', 1> = { a: 1 }
index = immutable // ok
index = record // ok
const foo = (obj: { [key: string]: any }) => {
obj['sdf'] = 2
return obj
}
const result1 = foo(immutable) // safe, see return type
const result2 = foo(record) // safe , see return type
It works, because TS does not try to infer obj
from generic argument. We can use any string we want as index.
Let's go back to our problem, now, TS tries to infer type of object
const foo = <T extends { [key: string]: any }>(obj: T) => {
obj['sdf'] = 2 // error
}
{[key:string]:any}
is too wide.
Examples of unsoundness:
let index: { [key: string]: any } = {}
let immutable = {
a: 'a'
} as const
let record: Record<'a', 1> = { a: 1 }
index = immutable // ok
index = record // ok
const foo = <T extends { [key: string]: any }>(obj: T) => {
obj['sdf'] = 2
return obj
}
const result1 = foo(immutable) // unsound, see return type
const result2 = foo(record) // unsound , see return type
Hence, while you can get value by key, it is safe to disallow mutations by key.
Btw, TS does not play well with mutations, because it can't track them. Here you have another one good example why it is better to avoid mutations
If TypeScript didn't know the key exists in T, result[key] should be an error regardless of reading or writing. I get why writing may be unsound when reading isn't, I think that first sentence is just a tangent.
If you read property - it can not affect T
object and you can return T
without any problems.
const foo = <T extends { [key: string]: any }>(obj: T) => {
const readOperation = obj['sdf']
// object still has T type
return obj
}
But if you mutate it:
const foo = <T extends { [key: string]: any }>(obj: T) => {
obj['sdf'] = 2
// this is not our good old `T` anymore,
return obj
}
TS can't figure out the return type of function. Type signature is
const foo: <T extends {
[key: string]: any;
}>(obj: T) => T
But it is not T
anymore, since it mutated and because TS does not track mutations - it is unsafe to do.
There are two problems with this code.
-
There is no type-safe way to iterate over an object with a generic set of keys.
-
Object.keys
returnsstring[]
and that's itended behavior. Though not suitable for iterating over your type.
You may rewrite your function as:
function reduceObjects<K extends string, T extends unknown>(currentValue: Record<K, T>, previousValue: Record<K, T>): Record<K, T> {
const result = Object.assign({}, previousValue) as Record<K, any>;
for (const key in result) {
const prev = result[key];
const curr = currentValue[key];
if (typeof prev === "number" && typeof curr === "number") {
result[key] = prev + curr;
} else if (typeof prev === "string" && typeof curr === "string") {
result[key] = prev + curr;
}
}
return result;
}
TS playground