Tuesday, February 16, 2021

Overview of Indexable Types In TypeScript

This blog post will look at Indexable Types. It is part of the Introduction to Advanced Types in TypeScript series.

In JavaScript, there are values, from which additional values can be retrieved via an index. A natural example of such a value is an Array: For example, given the following array:

const primaryColors =  ["red", "green", "blue"]

The value primaryColors is an array value, and its individual content can be accessed by providing an index of type number:

console.log(primaryColors[0])
console.log(primaryColors[1])
console.log(primaryColors[2])

This shows that an Array is an indexable type since an index can be supplied to an array to get back another value.

But Arrays are not the only Indexable type in TypeScript, our regular Objects are also indexable. As you probably know, there are two ways to set and access the properties of objects in Javascript. These two ways are the dot notation and the bracket notation. This is illustrated below:

var person = { name: "foobar", age: 42}
person.name // dot notation
person["name"] // bracket notation

Where the name is accessed via both dot notation and bracket notation. The bracket notation makes use of an index of type string, showing that objects are also indexable.

In TypeScript, these sorts of values that can be accessed via an index can be modeled as Indexable Types. Indexable Types gives us a mechanism to abstract away other properties of such values and only focus on the fact that they are indexable.

What does the syntax look like? Let's take a look at that next. 

For example, to express that a value can be indexed via a string and returned value will also be a string, such can be expressed using the following syntax:

type StringIndexed = {
  [index:string]:string
}

The index in the definition carries no special meaning, it is just a label and, instead of index, any alphanumeric value can be used. For example, the code snippet below is also a valid way to define an Indexable type:

type StringIndexed = {
  [foo123:string]:string
}

Note that due to interface and type being almost interchangeable, an indexable type can also be defined using an interface. Hence the above indexable type can also be represented as follows:

interface StringIndexed {
  [index:string]:string
}

A function can be defined that takes StringIndexed as input. Such a function would be able to be called any value that can be indexed by a string value and returns a string value.

function valByKey(values: StringIndexed, key: string) {
    return values[key]
}

const rgb = {
    "red": "100", 
    "blue": "010", 
    "green": "001"
}

const person = {
    name: "foo",
    age: "28"
}

console.log(valByKey(rgb, "red"))
console.log(valByKey(person, "age"))

Note that the above code snippet will compile when called with person and rgb, even though the structure of these two types are different. It works because they share a similar property of being indexable types.

Notice trying to call valByKey with an array will lead to a compilation error:

const rgb = ["red", "green", "blue"]

console.log(valByKey(rgb, "red"))
Argument of type 'string[]' is not assignable to parameters of type 'StringIndexed'. Index signature is missing in type 'string[]'  

This is because an array is indexed by a number and not a string. To express a structure that is indexable by a number, and returns a string value you will have

type NumberIndexed = {
  [index:number]:string
}

This is basically the idea behind indexable type and the syntax to define them. Even though the above covers the essence of indexable type, there are a couple of peculiarities to be aware of:

The Indexer can only be a string or number. Value can be of any type

Only two types are allowed for the index signature, and that is string or number. The value on the other hand can be of any valid TypeScript type. For example

type IndexedByNumber = {
  [type:number]: any
}

type IndexedBySring = {
  [type:string]: string | boolean | number
}

The above are all valid since the indexer is a string or number, while the value is a valid TypeScript type.

This on the other hand would be invalid:

type IndexedByBoolean = {
  [type:boolean]: string
}

And the error message would be:

An index signature parameter type must be either 'string' or 'number' 

It is also possible to make use of generics when specifying the type of the value of an indexable type.

type GenericIndex<T> = {
  [index:string]: T
}

If you are not familiar with Generics in TypeScript then do check Introduction to Generics in TypeScript 

It is valid to have both number and string indexer in indexable type definitions.

TypeScript allows indexable type definition that contains both a numeric and string indexer. For example:

type IndexedByStringAndNumber = {
  [type:string]: string
  [type:number]: string
}

The only restriction is that the value of the number indexed must be the same or subtype of the value of the string indexer.

The indexer can be made read-only

TypeScript also supports annotating the indexer type as read-only. This ensures the value at that index cannot be reassigned. For example, given the following indexable type definition with a read-only indexer

type Immutable = {
  readonly [index: number]: string
}

Attempting to update values at an index will fail to compile:

const values: Immutable = ["one", "two"]
values[0] = "un" // not allowed

With the error message:

Index signature in type 'Immutable' only permits reading

It is valid to have property definition with indexable type definitions

It is valid to have both an indexable type definition and property definitions in a single type. For example, this is valid:

interface Dictionary {
  [index: string]: number;
  length: number; 
}

This can be put to use in enforcing that a type together with any subtype must only support certain types as its property.

For example to define a structure where the type of the property can only be number or string you have:

interface NumberOrString {
  [index: string]: number | string;
  id: number; 
}

And even if this interface is extended, the compiler will ensure that the subtype also adheres to the constraints of having properties with only number or string type. That is, this is valid:

interface Person extends NumberOrString {
  name: string
  age: number
}

While an attempt to include a property that is not a string or number will lead to a compilation error. That is:

interface Person extends NumberOrString {
  name: string
  age: number
  married: boolean // not allowed
}

Will fail with the following error:

Property 'married' of type 'boolean' is not assignable to string index type 'string | number'

Attempting to implement the interface while sneaking in a property that is neither string nor number will also be rejected by the compiler, ensuring the desired constraint holds. For example, the following will not compile:

class Employee implements Person {
  [index: string]: string|number
  name: string = "John Doe"
  age: number = 31
  id: number = 1
  isManager: boolean = true // not allowed
}

It fails with the message:

Property 'isManager' of type 'boolean' is not assignable to string index type 'string | number'

Another use case of being able to have both property definitions with indexable type definitions is the ability to refine existing indexable types, like Array.

For example the default Array type, apart from itself being indexable also exposes a couple of properties and methods. For example, length, filter, find, etc. 


To see all properties and methods, see Array.prototype 

Now imagine you have a use case for a slim down indexable type, which has just some of the methods found in Array. Using indexable type definition and normal type definition makes it possible to define such type based on Array. For example, let’s say we only want to have an indexable type that supports only the length, pop, and push function from Array. We can define such type definition as follows:

type RefinedArray<T> = {
  [index:number]: T | number | string
  length: number
  pop(): T | undefined
  push(i: T): number
}

And can be used as follows:

let refinedArray: RefinedArray<String> = ["one", "two", "three"]

Where refinedArray will only support the method that is defined in RefinedArray while still remaining indexable like a normal array, as seen in the image below:


This post is part of a series. See Introduction to Advanced Types in TypeScript for more posts in the series.

No comments: