Skip to content
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

Type 'string' cannot be used to index type 'T' when indexing a generic function parameter #47357

Closed
saschanaz opened this issue Jan 9, 2022 · 28 comments Β· Fixed by #55906
Closed
Assignees
Labels
Bug A bug in TypeScript Domain: Error Messages The issue relates to error messaging Fix Available A PR has been opened for this issue
Milestone

Comments

@saschanaz
Copy link
Contributor

saschanaz commented Jan 9, 2022

Bug Report

πŸ”Ž Search Terms

Type 'string' cannot be used to index type 'T'.

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about index signature

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

function foo<T extends Record<string | symbol, any>>(target: T, p: string | symbol) {
  target[p]; // This is okay
  target[p] = ""; // This errors
}

πŸ™ Actual behavior

(parameter) target: T extends Record<string | symbol, any>
Type 'string' cannot be used to index type 'T'.(2536)
Type 'symbol' cannot be used to index type 'T'.(2536)

πŸ™‚ Expected behavior

Either:

  1. It should work, as the getter already works
  2. Or at least the error message should be changed as it's misleading, indexing itself has no problem.
@MartinJohns
Copy link
Contributor

The error is correct. You're not extending objects only having string typed properties, you're extending objects where the properties extend string . type Test = { a: 'hello' } is a type that matches your constraint, but assigning an empty string to the property a would not be correct.

@saschanaz
Copy link
Contributor Author

The error can't be correct, since it can be used to index type T. It's at least misleading.

@MartinJohns
Copy link
Contributor

We can argue that the error message is misleading and bad. But that an error happens is definitely correct. As my example demonstrates, assigning an empty string would be invalid.

Getting is not an issue here, because you're not using the value for anything. But in your setter you try to assign a potentially incompatible type.

@saschanaz
Copy link
Contributor Author

I'm not sure why { a: 'hello' } is correct and { a: '' } is not πŸ€”

@MartinJohns
Copy link
Contributor

MartinJohns commented Jan 9, 2022

The type of the property a is "hello", not the value (well, the value is too). It's a property that can only accept the string literal "hello", but it can not accept the string "". It's more common used with union types, e.g. "a" | "b". Meaning it can only be the string "a", or the string "b", but not any other string.

type Test = { val: "a" | "b" }
const t: Test = { val: "a" }
t.val = "b" // is okay
t.val = "" // is not okay

@saschanaz
Copy link
Contributor Author

saschanaz commented Jan 9, 2022

Oh thanks, now I see. The value type can be anything. (Modified OP as such)

@saschanaz
Copy link
Contributor Author

saschanaz commented Jan 9, 2022

But:

function foo<T extends Record<string, any>>(target: T, p: string) {
  target[p];
  target[p] = "hello"; // error
}

function foo2(target: Record<string, any>, p: string) {
  target[p];
  target[p] = "hello"; // no error
}

type Test = { val: "a" | "b" }
const t: Test = { val: "a" }

// no error at all
foo(t, "foo");
foo2(t, "foo");

Maybe this is just what we should expect by using any? πŸ€”

@fatcerberus
Copy link

fatcerberus commented Jan 9, 2022

A useful way to think about generics is that you're writing a function that works for all possible types that T could be; if this isn't true, you get an error in the implementation. You get no error at the call site, though, because there is no obvious issue with the call to foo() - Test is assignable to Record<string, any> and "foo" is a string. Obviously target[p] = "hello" is not a valid thing to do with a Test (or any number of other things you can pass to foo), so you get the error inside the function. It is a bit odd that target[p]; there is not also an error, though.

Technically the call of foo2() should be an error, and remains unsound even if you change the any to unknown, but that's a separate issue: TS treats object types covariantly. It's unsound for the same reason assigning a string[] to a (string | number)[] is unsound. The type system doesn't really model mutation well.

@fatcerberus
Copy link

If you don't want your random object types to be assignable to Record<string, T> you can use interfaces:

function foo(target: Record<string, unknown>, p: string) {
  target[p];
  target[p] = "hello"; // no error
}

interface Test { val: "a" | "b" }
const t: Test = { val: "a" }

// type error, `Test` has no index signature
foo(t, "foo");

Note that using Record<string, any> suppresses even this error.

@RyanCavanaugh RyanCavanaugh added Bug A bug in TypeScript Domain: Error Messages The issue relates to error messaging labels Jan 12, 2022
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Jan 12, 2022
@RyanCavanaugh RyanCavanaugh added the Help Wanted You can do this label Jan 12, 2022
@reisandbeans
Copy link

@fatcerberus Even though I went through the examples and discussion above, I'm not sure I understand exactly what is the root cause of this error. Check the following example:

interface Foo {
    [key: string]: number;
}

function test<T extends Foo>() {
    const a = {} as T;
    a['test'] = 1234; // <------------ error I get is: "test can't be used to index type T"
}

but why can't test be used? it is a string, and the constraint of T is extends Foo, where the interface for Foo specifies that the index signature is a string

@fatcerberus
Copy link

fatcerberus commented Apr 7, 2022

@Tinadim T is a type parameter, which means that it can be anything that's assignable to its constraint (i.e. extends is not necessarily a subtyping relationship). This is maybe a bit counterintuitive, but being constrained by a type with an index signature doesn't guarantee that T also has one:

interface Foo {
    [key: string]: number;
}

function test<T extends Foo>(a: T) {
    a['test'] = 1234;  // error
}

const x = { foo: 1, bar: 2 };
test(x);  // no error, valid call

test(x) is legal, but x['test'] = 1234; is not. Therefore, the assignment inside test is also invalid. When you write a generic function, it's not just the value that varies, but the type as well, so the compiler wants to ensure that what you write inside the function is valid for all possible types T can be.

@reisandbeans
Copy link

got it, thanks @fatcerberus!

@Terence625
Copy link

So, what's the good practice to index the generic type T inside the function?

@MartinJohns
Copy link
Contributor

@Terence625 Depends on what you want to do, but generally the answer is: Don't. Also, questions like this are better suited for platforms like StackOverflow.

@houfeng0923
Copy link

So , it need a graceful way to fix it .

demo playground

@verikono
Copy link

verikono commented Sep 17, 2022

Just a thought for anyone who finds themselves here. I ended up using a one liner taking into account what @MartinJohns said about "extending".

function foo<T extends Record<string | symbol, any>>(target: T, p: string | symbol) {
  target[p]; // This is okay
  target[p] = ""; // This errors
  target = Object.assign(target, {[p]:""}); // this is okay to.
}

be aware target is entirely replaced with a new object.

@pheuberger
Copy link

@Tinadim T is a type parameter, which means that it can be anything that's assignable to its constraint (i.e. extends is not necessarily a subtyping relationship). This is maybe a bit counterintuitive, but being constrained by a type with an index signature doesn't guarantee that T also has one:

interface Foo {
    [key: string]: number;
}

function test<T extends Foo>(a: T) {
    a['test'] = 1234;  // error
}

const x = { foo: 1, bar: 2 };
test(x);  // no error, valid call

test(x) is legal, but x['test'] = 1234; is not. Therefore, the assignment inside test is also invalid. When you write a generic function, it's not just the value that varies, but the type as well, so the compiler wants to ensure that what you write inside the function is valid for all possible types T can be.

what i find confusing is that i'm using a basic type like Foo or Record<string, number> and it still clashes. i would expect it to error out if i used a constraint like T extends {foo: number; bar: number}, but that's not the case here. sure, somebody can put in a type like {foo: number; bar: number;} and even though it doesn't have the key test it still is a Foo, so why shouldn't the function in your example return an object {foo: 1, bar: 2, test: 1234}?

maybe, what i would like to have is to be able to say something like T is Record<string, number> instead of T extends Record<string, number> so i can create a function that takes a somewhat generic dictionary, or array, or whatever and returns the same somewhat generic type but with more values or key value pairs than i put in.

@Stevenic
Copy link

Stevenic commented Dec 14, 2022

Just a thought for anyone who finds themselves here. I ended up using a one liner taking into account what @MartinJohns said about "extending".

function foo<T extends Record<string | symbol, any>>(target: T, p: string | symbol) {
  target[p]; // This is okay
  target[p] = ""; // This errors
  target = Object.assign(target, {[p]:""}); // this is okay to.
}

be aware target is entirely replaced with a new object.

I keep hitting this error over and over... Can I just say that if the workaround is to use Object.assign() then something is completely broken. I just don't get it... Why is it ok to read from target[p] but not assign to it? T is a type that extends a Record that has a string for a key and any type of value. If p is a string then I should be able to assign a value to that field. What am I missing????

My current workaround is to say screw you to the compiler and (target as any)[p] = ''; Guess what I have yet to encounter a runtime exception because there's nothing wrong with that assignment...

@Stevenic
Copy link

@fatcerberus my question is really simple... How can I say that T is a plain old JavaScript object that can have any fields keyed by a string? If I can't use Record<string, any> to do that what should I use? I'm tired of hitting this error.

@DiamondMofeng
Copy link

Reflect.set(obj, key, 1) works. But I'd also love to find a way to use obj[key]=1 directly

@fires3as0n
Copy link

What human intuitively expects when writing function<T extends Record<string, any>(arg: T) is arg to be of a dictionary type with the ability to additionally specify its exact shape.
From what I understand after reading this thread - unfortunately TypeScript is broken and does not work that way. Generic type can extend a type but the result is unpredictable.

@rmarscher
Copy link

It would be nice if an in operator check or the upcoming Object.hasOwn could be used to guard/narrow so that accessing via index by that value then works. Something like this:

interface A {
	a: string;
}
interface B {
	b: string;
}

export function test<T extends object>(x: T, field = 'a') {
	if (field in x) {
		return x[field];
	}
	// if (Object.hasOwn(x, field)) {
	// 	return x[field];
	// }
	return undefined;
}

const valueA: A = { a: 'yes' };
const valueB: B = { b: 'no' };
test(valueA);
test(valueB);

But that code still errors with No index signature with a parameter of type 'string' was found on type '{}'.

I don't want to add a [k: string]: unknown; definition to my interfaces because then I lose type checking when trying to access an unexpected property.

@Peeja
Copy link
Contributor

Peeja commented Apr 22, 2023

@Stevenic

How can I say that T is a plain old JavaScript object that can have any fields keyed by a string? If I can't use Record<string, any> to do that what should I use? I'm tired of hitting this error.

You can use Record<string, any> (in fact that's exactly what that means, although Record<string, unknown> would be safer). In the example in this issue, the type of target isn't Record<string, any>, it's some specialization of Record<string, any>. It could be Record<string, any>, but it also could be Record<"only this", any>. Clearly this shouldn't be okay:

function foo(target: Record<"only this", any>, p: string) {
  // We don't know that `p` is an acceptable key -- namely, `"only this"`.
  target[p] = "";
  // You also can't read with `p`, for the same reason.
  target[p]; // You can 
}

If you want to treat the argument as any record with string keys, you don't need a generic at all. You can just use that type:

function foo(target: Record<string, any>, p: string) {
  // Now `p` is just fine as a key.
  target[p] = "";
  target[p];
}

Now, there are two things I still don't understand, and which look like bugs to me on the face of them, but I haven't thought about these long:

function foo1<T extends Record<string, "value in the record">>(target: T, p: string) {
  // `v` is typed as `"value in the record"`, even though `p` may not be a valid key for a particular `T`.
  const v = target[p];
}

function foo2<T extends Record<string, string>>(target: T, p: keyof T) {
  // This assignment isn't allowed, even though `p` is typed as a valid key for any given `T`.
  target[p] = "";
}

(playground)

@gabritto gabritto removed the Help Wanted You can do this label Sep 27, 2023
@gabritto gabritto self-assigned this Sep 27, 2023
@gabritto
Copy link
Member

Here goes a sort of personal summary of the situation, based on my recent investigation of this issue.
Let me first say that index signatures are a bit confusing and inconsistent with the behavior of regular property declarations, so all the questions in this issue are very valid.

Now, why is the error happening?
The error happens because we have a rule that, when you are writing/assigning to an element access expression, you cannot use an index signature present in the constraint of a generic type. However, you can use that same index signature if you are reading from that same element access expression:

function foo<T extends Record<string, string>>(target: T, p: string) {
  target[p]; // This is okay because we're reading
  target[p] = ""; // This errors because we're writing to `target[p]`
  delete target[p]; // Confusingly, this doesn't error
}

If we think about it, writing target[p] = "" is not safe. Just imagine if we instantiate T with Record<string, "hello">, which is a more specific type than Record<string, string>: the type of target[p] would be "hello", but we would be assigning "" to it.

Ok, so we have a justification for not using the index signature of the constraint Record<string, string> of T when writing, so all is good, right? No, because in similarly unsafe situations, we do allow using an index signature or a property declaration of a type parameter's constraint when writing:

// Indirect assignment to index signature
function foo3<T extends Record<string, { a: string }>>(target: T, p: string) {
  target[p] = { a: "hi" }; // Error
  target[p].a = "hi"; // Same as above, but no error!
}

// Direct assignment with regular property declaration
function foo4<T extends { a: string }>(target: T) {
  target.a = "wow";
}
foo4<{ a: "not wow" }>({ a: "not wow" }); // Bad, will write "wow" to `a` when we expect "not wow".

So, from foo3 above we see that we're not very rigorous about not using an index signature on the left-hand side of an assignment.
And from foo4 above we see that regular property declarations (i.e. { a: string }) can potentially cause the same type violation/unsafety that we used to justify banning index signatures when writing, but we allow them just fine!

In conclusion, the rules are not very consistent, and I don't have a precise explanation of why it works the way it does, but it probably has to do with how convenient it is to allow the potentially unsafe pattern vs disallowing patterns that are likely to cause bugs.

@fatcerberus
Copy link

@gabritto I mean, foo3 is technically consistent under the current rules since target[p] is a read. The fact that you then go on to unsoundly mutate the object you pulled out is irrelevant (yes, it's unsound, but that's just object covariance for you; the index signature is out of the equation by this point).

I would like to argue that target[p] should always be an error even when reading, but that would likely be a massive breaking change so I don't expect that proposal to get much traction 😜

@gabritto
Copy link
Member

@gabritto I mean, foo3 is technically consistent under the current rules since target[p] is a read. The fact that you then go on to unsoundly mutate the object you pulled out is irrelevant (yes, it's unsound, but that's just object covariance for you; the index signature is out of the equation by this point).

Yes, the index signature rule is consistent with our definition of read, but that's a limited definition in the first place. I was trying to make a point that the two statements accomplish a very similar thing, and yet we disallow one and not the other. In the end, even with the index signature rule in place, you might still mutate something whose type was dictated by the index signature. Maybe that's better expressed as "the rule is incomplete". And yes, there are other rules at play as well that cause this to be permitted.

@Peeja
Copy link
Contributor

Peeja commented Oct 25, 2023

@gabritto Similarly, this hole exists:

const a: Record<string, "hello"> = {};
const b: Record<string, string> = a;

b.uhOh = "goodbye"
a.uhOh // == "goodbye"
// ^? "hello"

@9a8ri3L
Copy link

9a8ri3L commented Feb 2, 2024

But:

function foo<T extends Record<string, any>>(target: T, p: string) {
  target[p];
  target[p] = "hello"; // error
}

function foo2(target: Record<string, any>, p: string) {
  target[p];
  target[p] = "hello"; // no error
}

type Test = { val: "a" | "b" }
const t: Test = { val: "a" }

// no error at all
foo(t, "foo");
foo2(t, "foo");

Maybe this is just what we should expect by using any? πŸ€”

If I correctly understand, I can say the following, (Please anyone can correct me if there's something wrong):

Regarding to the above example, what purpose we have here to add types to the foo function?

  1. Constrain p to the target keys. [level 1]
  2. Constrain target[p] value to be "a"|"b". [level 2]
  3. Constraint target[p] value to be "a"|"b" based on p argument value. [level 3]
  4. ... and others

Let's start from [level 3] - because here's the point.

TS has no idea about the literal/union type of val in interface Test which is target[p].

So for a reason we should do one of the following:

  1. Explicitly tell TS that p is a key in target while assigning a value to it in the implementation of foo.
  2. Explicitly tell TS that we assigning target[p] to a value belongs to the target Record.
  3. Extract exactly the Test type to the target type.

I don't see any reason here for a generic T to extend a Record, while Record itself should receive its arguments from an extended <K,V> generics.

interface Test { val: "a" | "b", bar: 'foo' | 'baz' }
const t: Test = { val: "a", bar: 'baz' }

// level 3
function foo<K extends keyof Test, V extends Test[K]>(target: Record<K, V>, p: K, value: V) {
  target[p]; // okay
  target[p] = value // okay
  return target
}
console.log(foo(t, "val", 'a')) // p constrained to "val"|"bar", and target[p] constrained to the provided p.


// level 2
function _foo<K extends string,V extends string>(target: Record<K,V>, p: K) {
  target[p]; // okay
  target[p] = "" as V; // okay - assertion as V, regardless of type Test { val: "a" | "b", bar: 'foo' | 'baz' }
  return target
}
console.log(_foo<keyof Test, Test[keyof Test]>(t, 'val')) // constrained p "val"|"bar" 


// level 1
function __foo(target: {[k: string]: any}, _p: string) {
  const p = _p as keyof typeof target // cast - assertion p is a key in target
  target[p]; // ok
  target[p] = "abc"; // ok
  return target
}
console.log(__foo(t, 'def')) // non-constrained p

Playground

And that's exactly what I'm (as an old school) waiting from TS, while I'm learning :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript Domain: Error Messages The issue relates to error messaging Fix Available A PR has been opened for this issue
Projects
None yet
Development

Successfully merging a pull request may close this issue.