Blog-Archiv

Mittwoch, 2. Mai 2018

TypeScript Index Signatures

This Blog is about the attempt of TypeScript to keep developers from "shooting themselves in their feet".

1
2
3
4
5
interface Foot
{
    owner: string;
    [shot: string]: string;
}

The index signature is in line 4.

All examples here have been compiled with default compiler settings by

tsc *.ts

Maybe the results of my findings about index signatures can be modified by some compiler settings. In that case I will write as much versions of this Blog as settings exist :-)

The Shot in the Foot

JavaScript objects are maps, and maps are objects, I will use these terms synonymously in the following (although real Maps found their way to us via ES6). So this is about var object = {}.

JavaScript lets access objects by square brackets, e.g. object[something], whereby the key something will always by stringified before going into the map, no matter of what type it is. In other words, the key's toString() method will be called, and only the resulting string will be used as map-key. This can be quite misleading. Look at following JavaScript example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const foot = {};

const stringShot = "1";
const stringVictim = "String-indexed";

const numericShot = 1;
const numericVictim = "Numeric-indexed";

const objectShot = { toString: function() { return "1"; } };
const objectVictim = "Object-indexed";

foot[stringShot] = stringVictim;
foot[numericShot] = numericVictim;
foot[objectShot] = objectVictim;

console.assert(
    foot[stringShot] === stringVictim,
    "Value of string key value must not be overwritten!");
console.assert(
    foot[numericShot] === numericVictim,
    "Value of numeric key value must not be overwritten!");
console.assert(
    foot[objectShot] === objectVictim,
    "Value of object key value must not be overwritten!");

This is syntactically valid JavaScript. It defines three different key-value pairs, of type string, number and object. Then they get shot into foot. The numeric 1, the string "1", the object with "1" from toString(), all keys are put to the same map location, and thus overwrite each other. Just the last assert() will be successful, the first and the second will fail.

The TS Prevention

Compilíng this with TypeScript gives:

error TS2538: Type '{ toString: () => string; }' cannot be used as an index type.

This error refers to variable objectShot. That means, TS refuses to let index with an object-key, but still allows both number and string. Thus the number of possible shots in the foot was reduced to confusing number and string keys.

So will "Index Signatures" protect our feet?

About Index Signatures

TS calls the square bracket object access "indexing", and introduces the new term "index signature", also called "indexable type".

Here is how a TS programmer would write the JS example above. We learnt that TS refuses to let index by object, so the objectShot was removed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const foot: { [shot: string]: string } = {};

const stringShot = "1";
const stringVictim = "String-indexed";

const numericShot = 1;
const numericVictim = "Numeric-indexed";

foot[stringShot] = stringVictim;
foot[numericShot] = numericVictim;

console.assert(
    foot[stringShot] === stringVictim,
    "Value of string key value must not be overwritten!");
console.assert(
    foot[numericShot] === numericVictim,
    "Value of numeric key value must not be overwritten!");

The first line types foot to be an object that can be indexed by a string key, and can contain just strings. At least the code looks like this. In fact also number keys can be used without compile error, as we see in the subsequent code, where foot[numericShot] = numericVictim is done. This example is syntactically valid TS.

Just the second assert() succeeds, the first fails, because still the numeric 1 overwrites the string "1". Even when I change the index signature to

const foot: { [shot: number]: string } = {};

it would make no difference. The compiler doesn't care as long as the key is string or number.

So what is an index signature for, when it doesn't restrict the key to just one type?
It is the value type that gets restricted.

const foot: { [shot: string]: string } = {};

const numericShot = 1;
const numericVictim = 12345;

foot[numericShot] = numericVictim;

Compiling this with tsc gives

error TS2322: Type '12345' is not assignable to type 'string'.

because the index signature allows only strings as indexed values.
By the way, an indexed object can contain just objects of the data type defined by the index signature, in this case strings!

const foot: {
    length: number,
    [shot: string]: string
} = {
    length: 0
};

Compiling this gives:

error TS2322: Type '{ length: number; }' is not assignable to type '{ [shot: string]: string; length: number; }'.
  Property 'length' is incompatible with index signature.
    Type 'number' is not assignable to type 'string'.
error TS2411: Property 'length' of type 'number' is not assignable to string index type 'string'.

A Type Having Properties and Being Indexable

The final application that I find for index signatures is having a data type that can be accessed by properties (object.propertyName) and indexed by numeric or string keys (object[key]). Hm, isn't this like in JavaScript? The difference is that here all values can be just of one data type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
interface Foot
{
    owner: string;
    [shot: string]: string;
}

const foot: Foot = {
    owner: "TheOwner"
};
foot["1"] = "String-indexed";
foot[1] = "Numeric-indexed";

console.assert(
    foot.owner === "TheOwner",
    "Value of owner must be 'TheOwner'!");
console.assert(
    foot["1"] === "String-indexed",
    "Value of '1' must be 'String-indexed'!");
console.assert(
    foot[1] === "Numeric-indexed",
    "Value of 1 must be 'Numeric-indexed'!");

Here the index signature is used inside an interface Foot. The subsequent concrete foot implements that interface and puts a default value into the owner property. Then it indexes the object, once via string, once via number.

Unfortunately the numeric key erases the string key again. The first assert succeeds, because it goes to the named property. The second assert goes wrong, because the string "1" has been overwritten by the numeric 1. The third succeeds, because it asserts the "last shot".


Conclusion

That's how I understand index signatures:

  • restrict the value-type of named and indexed properties

In other words, if you restrict the value-type by an index signature, you can't have named properties of other types. What you obviously want, why else would you have created an object? If you want to index something: arrays and maps (→ real ES6 Maps) specialize on this!

Restrict the key-type to string and number? No, this would be checked even without an index signature!

  • Update: with compiler-switch "noImplicitAny": true, you can actually restrict the type of the key to just one type!

Index signatures do not enable indexing. This works anyway. Some say index signatures can loosen typing:

interface Foot
{  
    shot: string;  
    [excessProperty: string]: any; // allow additional properties of any type
}

Here the index signature is used to enable properties of any type within an object implementing the interface Foot.
So, we loosened typing. But why do we use TypeScript instead of JavaScript? Was it because we wanted the compiler to type-check and tell us about misplaced properties? Or was it because we wanted to continue shooting in our feet?

Last not least I plead for collecting all weapons and destroying them. Maybe this prevents shots in the feet :-?




Keine Kommentare: