109149
109149

Reputation: 1552

What are indexable types in typescript?

I was reading indexable types and "dictionary" pattern confused me:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // ok, length is a number
  name: string; // ok, name is a string
}
interface StringArray {
  [index: number]: string;
}

What is the difference between the two? How do you even initialize variable with the type of NumberOrStringDictionary? And why does NumberOrStringDictionary's fields (length and name) depend on index signatured field?

How is NumberOrStringDictionary different from this:

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;
}

Upvotes: 8

Views: 10033

Answers (2)

jcalz
jcalz

Reputation: 328097

This type:

interface NumberOrStringDictionary {
    [index: string]: number | string;
    length: number;
    name: string;
}

implies that a value of type NumberOrStringDictionary must contain a property named length of type number a property named name of type string. Additionally, a value of that type may contain any string-named properties, as long as the values of such properties are assignable to string | number.

A few points to clarify:

  • Index signatures may not conflict with any other keys. For example, the length property is of type number, so it is compatible with the index signature (number is assignable to number | string). And the name property is also compatible. If you tried to add a property of some incompatible type, you'd get an error:

    interface Oops extends NumberOrStringDictionary {
        acceptable: boolean; // error!
        //~~~~~~~~ <-- boolean not assignable to string | number
    }
    

    If I have a key of type string and I read the property at that key from a value of type NumberOrStringDictionary, what is the type of that property? It is number | string. Now imagine what would happen if you were allowed to add an incompatible property (like acceptable: boolean). In that case it would no longer be true. If the key I choose happens to be "acceptable", then a boolean comes out which is not number | string, so something is wrong. To be safe, you'd have to expect number | string | boolean.

    You can think of the non-index signature properties as being special cases of the index signature, where you know the property key definitely exists and that the value is of some possibly more specific type. The known properties should be thought of as particular/special cases of the indexer. "All properties of this object are of type string | number. In particular, the length property of this object is of type number, and the name property of this object is of type string". It's like saying "My pets are all dogs. Fido here is a poodle." Incompatibility would look like "My pets are all dogs. Fido here is a parakeet". You can say that, but it's not consistent. You might be thinking of index signatures as a "default" or "everything else" or "rest" case, ("My pets are all dogs except for this parakeet") but that's not what it means in TypeScript. There is an open issue, microsoft/TypeScript#17867 asking for such a construct in the language, but it is not yet there (there are workarounds).

  • An index-signature does not generally mean that all string-keyed properties will appear. You are allowed to put as many or as few such properties as you'd like. It is therefore possible that when you inspect a property of type NumberOrStringDictionary, it will be missing. When you read such a missing property, you will get a value of type undefined, not of string | number, but the compiler will pretend that it is string | number. TypeScript 4.1 introduced a compiler flag to treat undefined as a possible result of any index signature property read, but it is not on by default.


Perhaps it would be helpful to go through some examples and see what is accepted and what is rejected by the compiler. Here's a valid assignment:

const valid: NumberOrStringDictionary = {
    length: 1,
    name: "bob",
    someOtherKey: 123,
    someOtherKey2: "hey"
};

The requisite length and name properties are in there, and the additional properties match the index signature. This is also valid:

const alsoValid: NumberOrStringDictionary = {
    length: 1,
    name: "bob",
};

because you don't have to add any such index-signature properties. Now for the mistakes:

const invalid1: NumberOrStringDictionary = { // error!
    //~~~~~~~~ <- property "name" is missing
    length: 1,
    someOtherKey: 123,
    someOtherKey2: "key"
};

You can't leave out a required property. Also:

const invalid2: NumberOrStringDictionary = {
    length: "fred", // error!
    //~~~~ <-- string is not assignable to number
    name: "bob",
    someOtherKey: 123,
    someOtherKey2: "key"
};

You can't put the wrong type of the required property. And finally:

const invalid3: NumberOrStringDictionary = {
    length: 1,
    name: "bob",
    someOtherKey: 123,
    someOtherKey2: true // error!
    //~~~~~~~~~~~ <-- boolean is not string | number
};

You can't add a property that conflicts with the index signature.


Playground link to code

Upvotes: 8

Lesiak
Lesiak

Reputation: 25966

Objects in JS can be thought of as a dictionary. They can take arbitrary keys, which map to corresponding values. This object - dictionary duality is also visible in syntax:

const o = {key: 1};
console.log(o['key']);
console.log(o.key)

Properties can be added and removed in runtime.

In general, most objects have a constant, known set of properties, and you model them as regular interfaces in TypeScript.

You need to use an indexed type if you want to model objects which follow JS rules

  • can contain any property
  • can be modified in runtime by adding and deleting properties

Note that there are 2 kinds of indices

  • number - you can only index via a number (which means you use them like an array)
  • string - you can index via any string (you use them like a dict)

In your example:

interface StringArray {
  [index: number]: string; // number index
}
interface NumberOrStringDictionary {
  [index: string]: number | string;   // string index
  length: number; 
  name: string; 
}

You define the variables of these interfaces as follows:

const myArray: StringArray = ["Bob", "Fred"];
console.log(myArray[0])

const d: NumberOrStringDictionary = {
  a: 1,
  b: 2
  length: 2,
  name: 'myDict'
}

// You can add and remove properties in runtime
d.c = 1;
console.log(d['c'])
delete d.b;
console.log(d['c'])

Indexed types allow you to specify the types of values of the properties.

const d1: NumberOrStringDictionary = {
  a: true,  // error: Type 'boolean' is not assignable to type 'string | number'.
  b: {},    // error: Type '{}' is not assignable to type 'string | number'.
  length: 1,
  name: 'myDict'
}

As described in the link from original post, if you add additional properties to a string-indexed type, the types of these properties must be compatible with the type specified for the index.

Upvotes: 2

Related Questions