Index Signatures

An Object in JavaScript (and hence TypeScript) can be accessed with a string to hold a reference to any other JavaScript object.

Here is a quick example:

  1. let foo:any = {};
  2. foo['Hello'] = 'World';
  3. console.log(foo['Hello']); // World

We store a string "World" under the key "Hello". Remember we said it can store any JavaScript object, so lets store a class instance just to show the concept:

  1. class Foo {
  2. constructor(public message: string){};
  3. log(){
  4. console.log(this.message)
  5. }
  6. }
  7. let foo:any = {};
  8. foo['Hello'] = new Foo('World');
  9. foo['Hello'].log(); // World

Also remember that we said that it can be accessed with a string. If you pass some any other object to the index signature the JavaScript runtime actually calls .toString on it before getting the result. This is demonstrated below:

  1. let obj = {
  2. toString(){
  3. console.log('toString called')
  4. return 'Hello'
  5. }
  6. }
  7. let foo:any = {};
  8. foo[obj] = 'World'; // toString called
  9. console.log(foo[obj]); // toString called, World
  10. console.log(foo['Hello']); // World

Note that toString will get called whenever the obj is used in an index position.

Arrays are slightly different. For number indexing JavaScript VMs will try to optimise (depending on things like is it actually an array and do the structures of items stored match etc.). So number should be considered as a valid object accessor in its own right (distinct from string). Here is a simple array example:

  1. let foo = ['World'];
  2. console.log(foo[0]); // World

So that’s JavaScript. Now let’s look at TypeScript graceful handling of this concept.

TypeScript Index Signature

First off, because JavaScript implicitly calls toString on any object index signature, TypeScript will give you an error to prevent beginners from shooting themselves in the foot (I see users shooting themselves in their feet when using JavaScript all the time on stackoverflow):

  1. let obj = {
  2. toString(){
  3. return 'Hello'
  4. }
  5. }
  6. let foo:any = {};
  7. // ERROR: the index signature must be string, number ...
  8. foo[obj] = 'World';
  9. // FIX: TypeScript forces you to be explicit
  10. foo[obj.toString()] = 'World';

The reason for forcing the user to be explicit is because the default toString implementation on an object is pretty awful, e.g. on v8 it always returns [object Object]:

  1. let obj = {message:'Hello'}
  2. let foo:any = {};
  3. // ERROR: the index signature must be string, number ...
  4. foo[obj] = 'World';
  5. // Here is what you actually stored!
  6. console.log(foo["[object Object]"]); // World

Of course number is supported because

  1. its needed for excellent Array / Tuple support.
  2. even if you use it for an obj its default toString implementation is nice (not [object Object]).

Point 2 is shown below:

  1. console.log((1).toString()); // 1
  2. console.log((2).toString()); // 2

So lesson 1:

TypeScript index signatures must be either string or number

Quick note: symbols are also valid and supported by TypeScript. But let’s not go there just yet. Baby steps.

Declaring an index signature

So we’ve been using any to tell TypeScript to let us do whatever we want. We can actually specify an index signature explicitly. E.g. say you want to make sure than anything that is stored in an object using a string conforms to the structure {message: string}. This can be done with the declaration { [index:string] : {message: string} }. This is demonstrated below:

  1. let foo:{ [index:string] : {message: string} } = {};
  2. /**
  3. * Must store stuff that conforms the structure
  4. */
  5. /** Ok */
  6. foo['a'] = { message: 'some message' };
  7. /** Error: must contain a `message` or type string. You have a typo in `message` */
  8. foo['a'] = { messages: 'some message' };
  9. /**
  10. * Stuff that is read is also type checked
  11. */
  12. /** Ok */
  13. foo['a'].message;
  14. /** Error: messages does not exist. You have a typo in `message` */
  15. foo['a'].messages;

TIP: the name of the index signature e.g. index in { [index:string] : {message: string} } has no significance for TypeScript and really for readability. e.g. if its user names you can do { [username:string] : {message: string} } to help the next dev who looks at the code (which just might happen to be you).

Of course number indexes are also supported e.g. { [count: number] : SomeOtherTypeYouWantToStoreEgRebate }

All members must conform to the string index signature

As soon as you have a string index signature, all explicit members must also conform to that index signature. This is shown below:

  1. /** Okay */
  2. interface Foo {
  3. [key:string]: number
  4. x: number;
  5. y: number;
  6. }
  7. /** Error */
  8. interface Bar {
  9. [key:string]: number
  10. x: number;
  11. y: string; // ERROR: Property `y` must be of type number
  12. }

This is to provide safety so that any string access gives the same result:

  1. interface Foo {
  2. [key:string]: number
  3. x: number;
  4. }
  5. let foo: Foo = {x:1,y:2};
  6. // Directly
  7. foo['x']; // number
  8. // Indirectly
  9. let x = 'x'
  10. foo[x]; // number

Using a limited set of string literals

An index signature can require that index strings be members of a union of literal strings by using Mapped Types e.g.:

  1. type Index = 'a' | 'b' | 'c'
  2. type FromIndex = { [k in Index]?: number }
  3. const good: FromIndex = {b:1, c:2}
  4. // Error:
  5. // Type '{ b: number; c: number; d: number; }' is not assignable to type 'FromIndex'.
  6. // Object literal may only specify known properties, and 'd' does not exist in type 'FromIndex'.
  7. const bad: FromIndex = {b:1, c:2, d:3};

This is often used together with keyof typeof to capture vocabulary types, described on the next page.

The specification of the vocabulary can be deferred generically:

  1. type FromSomeIndex<K extends string> = { [key in K]: number }

Having both string and number indexers

This is not a common use case, but TypeScript compiler supports it nonetheless.

However it has the restriction that the string indexer is more strict than the number indexer. This is intentional e.g. to allow typing stuff like:

  1. interface ArrStr {
  2. [key: string]: string | number; // Must accomodate all members
  3. [index: number]: string; // Can be a subset of string indexer
  4. // Just an example member
  5. length: number;
  6. }

Design Pattern: Nested index signature

API consideration when adding index signatures

Quite commonly in the JS community you will see APIs that abuse string indexers. e.g. a common pattern among CSS in JS libraries:

  1. interface NestedCSS {
  2. color?: string;
  3. [selector: string]: string | NestedCSS;
  4. }
  5. const example: NestedCSS = {
  6. color: 'red',
  7. '.subclass': {
  8. color: 'blue'
  9. }
  10. }

Try not to mix string indexers with valid values this way. E.g. a typo in the padding will remain uncaught:

  1. const failsSilently: NestedCSS = {
  2. colour: 'red', // No error as `colour` is a valid string selector
  3. }

Instead seperate out the nesting into its own property e.g. in a name like nest (or children or subnodes etc.):

  1. interface NestedCSS {
  2. color?: string;
  3. nest?: {
  4. [selector: string]: NestedCSS;
  5. }
  6. }
  7. const example: NestedCSS = {
  8. color: 'red',
  9. nest: {
  10. '.subclass': {
  11. color: 'blue'
  12. }
  13. }
  14. }
  15. const failsSilently: NestedCSS = {
  16. colour: 'red', // TS Error: unknown property `colour`
  17. }