The big advantage of TypeScript over plain JavaScript is that it extends the features of JavaScript by adding functionality that ensures the type safety of our program’s objects. It does this by checking the shape of the values that objects take on.
Checking the shape is called duck typing or structural typing. Interfaces are one way to fill the role naming data types in TypeScript. It’s very useful for defining contracts within our code in TypeScript programs. In the last article, we looked at how to define a TypeScript interface and adding required and optional properties to it. In this article, we’ll continue to look at other properties of TypeScript interfaces like indexable types.
Indexable Types
We can define indexable types for data like arrays. Any object that uses bracket notation like arrays and dynamic object types can be designated with indexable types. Indexable types have an index signature that describes the types that we can use as an index for our object, alongside the return type for the corresponding index. It’s very handy for designating the types for dynamic objects. For example, we can design an array that only accepts strings like in the following code:
interface NameArray {
[index: number]: string;
}
let nameArray: NameArray = ["John", "Jane"];
const john = nameArray[0];
console.log(john);
In the code above, we defined the NameArray
interface that takes in a index
that is of type number
as the index signature, and the return type of the corresponding index signature is a string. Then when we designate a variable with the NameArray
type then we can use the index to get the entries of the array. However, with this code, the array methods and operators aren’t available since we only have the [index: number]
index signature and nothing, so the TypeScript compiler isn’t aware that it’s an array even though it looks like one to the human eye.
Index signatures support 2 types. They can either be strings or numbers. It’s possible to support both types of indexes, but the type returned from a numeric indexer must be a subtype of the one returned by the string indexes. This is because JavaScript will convert numeric indexes to strings when it’s trying to accessing entries or properties with numeric properties. This ensures that it’s possible to get different results returned for the same index.
For example, the following code would give us an error from the TypeScript compiler:
class Animal {
name: string = '';
}
class Cat extends Animal {
breed: string = '';
}
interface Zoo {
[x: number]: Animal;
[x: string]: Cat;
}
If we try to compile the code above, we would get “Numeric index type ‘Animal’ is not assignable to string index type ‘Cat’.(2413)”. This is because we have Cat
as a return type of the string index, which is a subtype of Animal
. We can’t have this since if we have 2 index signatures with different types, then the supertype must be the return type of the index signature with the string type, and the index signature with the number type must have the subtype of the of returned by the one with the string index signature. This means that if we flip the return types around, then code will be compiled and run:
class Animal {
name: string = '';
}
class Cat extends Animal {
breed: string = '';
}
interface Zoo {
[x: number]: Cat;
[x: string]: Animal;
}
Since Animal
is a supertype of Cat
, we must have Animal
as the return type of the string index signature, and the Cat
type as the return type of the number index signature.
Photo by Nathalie SPEHNER on Unsplash
Index signatures enforce that all normal property matches their return type in addition to the ones that are accessed by the bracket notation since in JavaScript obj.prop
and obj['prop']
are the same. This means that if we have the following code:
interface Dictionary {
[x: string]: string;
}
let dict: Dictionary = {};
dict.prop = 1;
Then we would get the error “Type ‘1’ is not assignable to type ‘string’.(2322)” since we specified that all properties are strings in the variable that has the Dictionary
type. If we want to accept other types in the properties of our objects, we have to use union types. For example, we can write the following interface to let the properties of the object with the given type accept both string and numbers as values:
interface Dictionary {
[x: string]: string | number;
num: number;
}
let dict: Dictionary = { num: 0 };
In the example above, we accept both string
and number
as both types of our values. So we add a property with a number
type without the TypeScript compiler rejecting the code with an error. Therefore, in the last line of the code above, we can add a num
property to the object with the value 0.
We can also make an index signature readonly
so that we can prevent assignment to their indices. For example, we can mark an index signature as read only with the following code:
interface Dictionary {
readonly [x: string]: string;
}
let dict: Dictionary = {'foo': 'foo'};
Then when we try to assign another value to dict['foo']
like in the code below, the TypeScript compiler will reject the code and won’t compile it:
interface Dictionary {
readonly [x: string]: string;
}
let dict: Dictionary = {'foo': 'foo'};
dict['foo'] = 'foo';
If we try to compile the code above, we’ll get the error “Index signature in type ‘Dictionary’ only permits reading.(2542)”. This means that we can only set the properties and values of a read only property when the object is being initialized, but subsequent assignments will fail.
Conclusion
Indexable types are very handy for defining the return values of the properties of dynamic objects. It takes advantage of the fact that we can access JavaScript properties by using the bracket notation. This is handy for properties that have invalid names if defined without the bracket notation or anything that we want to be able to be accessed by the bracket notation and we want type checking on those properties or entries. With indexable types, we make sure that properties that are assigned and set by the bracket notation have the designated types.
Also, this also works for regular properties since bracket notation is the same as the dot notation for accessing properties. Also, we can designate index signatures as readonly
so that they can be written to when the object with a type with indexable types is initialized but not after. If we have both number and string index signatures, then the string indexable signature must have the return type that’s the super-type of the one with the number index signature so that we get consistent types for objects when we access properties.