Friday, April 02, 2021

Tuple Types in TypeScript

In this post, we would be looking at tuple types in TypeScript. It will also touch on generic rest parameters and variadic tuple types which are two of the advanced parts of tuple types. 

This post is part of the Introduction to Advance Types in TypeScript series and it builds on the knowledge around generics in Typescript and also spread and rest syntax in Javascript. In case you are not familiar with these concepts, then take the time to first read Introduction to Generics in TypeScript and Spread and Rest in JavaScript first. 

What is a tuple type?

First, let's start by defining what tuple types are. A tuple type is a type used to ascribe type annotations to a collection of values, somewhat similar to array types, with some key distinguishing characteristics.

A tuple type is different from an array in the sense that it can also specify exactly how many elements it contains. This can't be done with a normal array.

The other difference between an array and a tuple is the fact that tuple types are heterogeneous, meaning they can contain values of different types, while arrays always contain values of the same type. For example, an array of type number[] can only contain numeric values, while an array of string[] can only contain strings. No such restrictions exist for tuple types. 

It won't be wrong to see tuples as a specialized array. A tuple type can then be seen as a special type of array that can contain values of different types, known length and it knows the type of the values at specific positions.

Next question then is, how are type types defined? Like so:
type Point = [number, number, number] 
type NameAndAge = [string, number]
The first tuple type definition, Point is defined to contain only 3 numeric values. Any deviation from this will not compile:
const point:Point = [4,5,3] // okay
const point:Point = [1,2] // not okay
const point:Point = ["one", "two", "three"] // not okay
The second type NameAndAge is defined to contain only two values, with the first value being of type string and the second being a numeric type. Any deviation from this will not compile.
const user: NameAndAge = ["Jon", 20] // Ok
const user: NameAndAge = [20, "Jon"] // Not Ok
const user: NameAndAge = [1,2,3] // Not Ok 
This is the general idea behind tuples. But as you probably guessed there is more to tuples, if not, there won't be the need to explore them within a series of Advance types in TypeScript.

The next sections would then explore some of the characteristics and usage of tuple types that can be considered as going past basic usage.

Advanced Tuple Types

Tuple types in Typescript come with some features that enable some advanced type operations. One way to think about these advanced type operations is being able to perform static type guarantees on a collection of values, which won't be possible if arrays are used instead to model the collection of values. In another sense, tuples provide more power over arrays.

Typing Rest Parameters

We will start by looking at relatively simple use cases of tuple types and then build that up. 

Javascript has rest parameters. For example 
function add(...args) {
  console.log(args)
}
The question then is, how does one assign a type to rest parameters in Typescript? It is possible to type ...args as an array, but arrays should contain the same types, and function arguments do not necessarily have to be of the same type. So to deal with this, we can type ...args as any[] but this might not be desirable as it provides less type safety.

It is possible to type rest parameters with a tuple type. An attempt to do that with the add script above will look like this:
function add(...args:[number, string, boolean]) {
  console.log(args)
}
But hey, this also has its drawback. Ideally, when an argument to a function is a rest parameter it signifies that the function can be called with a variable amount of parameters. But once we use a tuple type for the rest parameter, we remove this variability and constraint the number of arguments to the number of types specified in the tuple types. This is essentially the same as not using rest parameters. That is, this:
function add(...args:[number, string, boolean]) {
  console.log(args)
}
Is more or less the same as this:
function add(arg1:number, arg2:string, arg3:boolean) {
  console.log(args)
}
Hence using tuple types to type the rest parameter this way does not provide any substantial benefit. 

The next question is, would it be possible to still type rest parameters with tuple types and still keep the variability of rest parameters? This is what we look at next:

Generic rest parameter list

To be able to use tuple types to type rest parameters and still keep the variability nature of rest parameters, we bring in generic to the mix.

The trick on how this works depends on the type inference process of the typescript compiler.

So what we do is instead of annotating the ...args with a specific tuple type, we annotate with a generic type, and in the generic definition, we say this generic type needs to extend any[]. This looks like so:

function add<T extends any[]>(...args:T) {
  console.log(args)
}
You notice nowhere do we explicitly specify tuple types, but when the typescript compiler sees this type of construct, whatever is arguments that are used to call the function is then inferred as tuple types.

One application of this construct in providing type safety is the scenario where we have a function that takes two parameters: a function and a list of arguments that should be passed to the first function. By using generic rest parameters, it is possible to guarantee that the list of arguments matches the number of arguments the function is expecting.

In code this looks:
function add<T extends any[]>(f:(...args:T) => number, ...args:T) {
  console.log(f(...args))
}

const sum = (x:number, y:number, z:number) => {
  return x + y + z
}

add(sum, 1, 2, 3)

With this construction, the extra arguments that are passed to add must match up with the number of arguments sum is expecting. If this is not the case, the compiler knows and won't compile. For example, this won't compile:
// sum expects 3 parameters
// only two is provided
add(sum, 1, 2)

Variadic Tuple Types

The next tuple-based feature we will explore will be variadic tuple type. To explore this feature we start with some motivations. To start with this discussion, we first review the tuple syntax.

As mentioned above, tuple type syntax takes the following form:
type Point = [number, number, number]
const val: Point = [1,2,3]
It is also possible to include an array within the tuple type syntax
type Point = [number, number, number[]]
const val:Point = [1, 2, [3, 4]] 
But not just this, it is possible to use the spread syntax within the tuple type syntax:
type Point = [number, number, ...number[]]
const val:Point = [1, 2, 3, 4]
One of the characteristics of tuple types is that they have a known length. It is interesting to point out that using the spread syntax actually puts a spin on this because with it a tuple type can now contain any varied number of values.
type Point = [number, number, ...number[]]
const val:Point = [1, 2, 3, 4]
const val:Point = [1, 2, 3, 4, 5] 
The next thing to note with the tuple type syntax is that it is possible to have the spread parameter be generic. That is:
type Point<T extends any[]> = [number, number, ...T]
const val:Point<number[]> = [1, 2, 3, 4]
This is variadic tuple types, and it is a feature that provides us a mechanism for constructing tuple types. For example:
type StringPadded<T extends any[]> = [string, ...T, string]

type NumWithStr = StringPadded<number[]>
const numWithStr:NumWithStr = ["a", 1, 2, 3, "z"]

type BolWithStr = StringPadded<boolean[]>
const BolWithStr: BolWithStr = ["a", true, false, "z"]
Here we have the tuple types NumWithStr and BolWithStr created from StringPadded

This ability to construct tuple types this way provides us with a mechanism to model operations that involves construction or a combination of tuple types. For example, the famous tail function that, given an array, removes the first element in an array and returns the rest.
function tail<T extends any[]>(arr: [any, ...T]) {
  const [_i, ...rest] = arr;
  return rest;
}
Or the Concat function which involves combining two arrays. With the arrays model as tuple types and making use of variadic tuple types, it is possible to statistically check that the result of the concatenation operation is based on the input array and also that the length adds up. In code this looks like this:
function concat<T extends any[], U extends any[]>(arr1: [...T], arr2: [...U]): [...T, ...U] {
  return [...arr1, ...arr2];
}
This allows us to have type safety over the result of the concat. For example:
const res = concat([1,2], ["3","4"])
const num1: number = res[0]
const num2: number = res[1]
const str1: string = res[2]
const str2: string = res[3]
The compiler can statistically verify that the result of concat([1,2], ["3","4"]) would have to be of type [number, number, string, string]. We can confirm this by trying to assign a wrong type parameter as shown below:
const res = concat([1,2], ["3","4"])
const num1: boolean = res[0]
This will fail with the following error:

Type 'number' is not assignable to type 'boolean.

So whenever you have operations that involve computing arrays from other arrays or other types, consider modelling the array as a tuple type and make use of variadics to ensure more type safety.


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

Are you a developer coming to typescript from other programming languages? Then you will find my upcoming book useful: TypeScript Beyond The Basics. Sign up here to be notified when it is ready.

No comments: