Damien Sawyer
Damien Sawyer

Reputation: 5907

string cannot be used to index type 'T'

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

Answers (2)

arslan2012
arslan2012

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

Al-un
Al-un

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

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.

Upvotes: 5

Related Questions