TypeScript: advanced and esoteric

Published on August 24, 2022

cover picture

In this article, we’ll explore lesser-known features of TypeScript that make it more dynamic.

The first part will cover dynamic types that rely on generics to create new types that may look very different from each other, depending on the type arguments they receive. This part will include property accessors, conditional, inferred, and recursive types, and it falls under the “advanced” category.

In the second “esoteric” part, we will use these types to implement addition and subtraction logic on numeric literals. No JavaScript, only types. If you already know how to use the aforementioned advanced types, feel free to skip straight to that section.

Property accessors

When we work with types that describe objects with nested structures, we can use property accessor syntax to derive new types based on properties and sub-properties of that parent type. Consider this type that represents an API response containing a list of users in its data property:

type ApiResponse = {
  data: Array<{
    firstName: string
    lastName: string
    age: number
  }>
  meta: {
    page: number
    count: number
  }
}

We can use property accessor syntax to create a new type that will represent a list of users by accessing the data property:

type UserList = ApiResponse["data"]

// is equal to
// => type UserList = Array<{ firstName: string, lastName: string, age: number }>

To go even further, we can use array accessor syntax and create a type for an individual user:

type User = UserList[number]

// is equal to
// type User = { firstName: string, lastName: string, age: number }

This is a very common way of using property accessors, but things get more interesting when we combine property accessors with generic types. When we restrict the type argument of a generic to a particular type set, we can use property accessor syntax to access properties of the types that belong to that set. For example, we can restrict a type argument to any[] (which includes all array types) and get access to properties that are available on any array. In the example below, we access the length property which creates a numeric literal type equal to the number of elements in the X array:

type Length<X extends any[]> = X["length"]

type Result1 = Length<[]>         //  => 0
type Result2 = Length<[1, 2, 3]>  // => 3

This type Length, although isn’t very useful in real applications, will come in handy later in this article when we get to a more esoteric use of TypeScript. For now, though, we can use it to demonstrate two interesting ideas.

The first one is the use of the keyword extends. The exact meaning of it is that a type set on the left side of extends is included in a type set on the right side. In practice, these supertype/subtype relations can become quite complex and difficult to interpret, so depending on the context, we can read them as “inherits from”, “equals to”, or even “kind of looks like” relations. We will see the different uses of extends as we go through the examples in this article.

Secondly, we can draw the similarity between the syntax of a TS type and a syntax of a JavaScript function mimicking its functionality:

// a type
type Length<X extends any[]> = X["length"]

// a function
const length = (array: any[]) => array.length

Notice how different tokens of a type map to tokens of a JS function:

  • type name → function name
  • type argument → function argument
  • type argument restriction → function argument type
  • type definition → function body

It’s useful to keep this mapping in mind as it will make understanding more complex types easier.

Conditional types

Conditional types allow us to introduce if/else logic in the type definitions and make them more dynamic. In TypeScript, we always use ternary syntax to define conditional statements. They begin with a condition itself using SomeType extends SomeOtherType notation, followed by true and false branches of that condition.

Here’s a very basic conditional type that will create either true or false literal types, depending on the type parameter it receives:

type IsOne<X> = X extends 1 ? true : false

type Result1 = IsOne<1>      // => true
type Result2 = IsOne<2>      // => false
type Result2 = IsOne<"1">    // => false

It is also possible to nest multiple ternary operators to describe more complex conditions. In the following example, we define a type that extracts the item type of some array types:

type ItemType<A> = A extends Array<number>
  ? number
  : A extends Array<string>
  ? string
  : A extends Array<boolean>
  ? boolean
  : unknown

type Result1 = ItemType<Array<number>>  // => number
type Result2 = ItemType<Array<string>>  // => string
type Result3 = ItemType<Array<{}>>      // => unknown

This implementation of ItemType supports only number, string, and boolean arrays, and will default to unknown type otherwise. We could include more nested ternary operators to support other types as well, but it quickly becomes impractical. There’s a better and more robust way of implementing this logic, but to understand it, we need to cover type inferring first.

Type inferring

Conditional types are useful on their own, but they become particularly powerful when used together with the infer keyword. This keyword lets us define a variable within our extends constraints, so we can later reference or return this variable from within the type definition.

To see it in action, we’ll redefine our ItemType to become universal and support any type of array:

type ItemType<A> = A extends Array<infer I> ? I : unknown;

type Result1 = ItemType<number[]>;      // => number
type Result2 = ItemType<string[]>;      // => string

In the above example, we’re using a similar logic with extends, only instead of specifying the exact item type for the array, we let TypeScript figure it out and preserve it in I type variable that we can then return.

A combination of conditional types and the infer keyword appear in many utility types that come with TS. One such type is Awaited, which “unwraps” a promise and returns the type of value the promise would resolve to. The exact implementation of Awaited is more complex to account for different kinds of promises, but the simplified version would be very similar to how our ItemType is done:

type Unwrap<T> = T extends Promise<infer I> ? I : never

type Result = Unwrap<Promise<{ status: number }>>  // => { status: number }

Another interesting type that comes with TS is ReturnType. This one takes a function type expression as an argument, and creates a type from the return type of that function:

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any

type Result = ReturnType<(s: string) => number>  // => number

In this implementation, we rely on the extends keyword twice. The one on the left makes sure that only function types can be passed in, and the one on the right defines the condition. This condition matches the T argument against a function signature, discarding the types of its arguments and only preserving the return type R.

Recursive types

A recursive type is a type that references itself within its definition. Recursive types have many applications, but one of the most common ones is a replacement for loops. TypeScript doesn’t have a dedicated keyword for making loops, but we can get around this limitation by using recursion to repeatedly execute some logic.

To see recursion in action, we’ll once again refer to ItemType, only this time we’ll make it work with N-dimensional arrays (arrays of arrays):

type ItemType<A> = A extends Array<infer I>
  ? ItemType<I>
  : A

type Result1 = ItemType<number[]>      // => number
type Result2 = ItemType<number[][][]>  // => number

The important thing to notice here is that we’re referencing ItemType within its definition. This allows us to check if the array item type is an array itself by “peeling off” layers of arrays and passing the result back to ItemType. Only when we get to the inner item type that the condition becomes false and we exit the loop.

Getting esoteric

Now that we’ve covered all these types, let’s see them in practice. Usually, I’d advocate for practicing on real-life examples, but not in this situation. The use cases where these types come in handy aren’t very common and require a long discussion to understand the context of each use case, and that would inevitably shift focus away from the important stuff. So instead of real world, we’ll go with fun and esoteric.

We’re going to implement Add and Sub types, which will take 2 numeric arguments each and either add or subtract them. These types won’t have any practical use at all, but they will provide us with plenty of opportunities to practice what we’ve learned in a creative way.

Addition

Let’s start by building Add type first, which should work like this:

type Add<A extends number, B extends number> = // to be defined

type Result = Add<3, 2>   // => 5

The first problem we run into is that TypeScript cannot do much with numeric literals (nor with other primitive types for that matter). TypeScript wasn’t designed to be used in that way, and it’s important to acknowledge its limitations. However, if we look at array types, we’ll discover that we can use a spread operator, and that simple fact gives us a lot of control.

With a spread operator, we can make quite a few transformations - we can concatenate arrays, get the first and last elements of the array, as well as append, prepend, and remove individual elements. As you may have already guessed, we’re going to use arrays to get around the limitation with the numeric literals we mentioned.

To implement our Add type, we will follow this set of steps:

  1. convert numeric literals into tuples of the equivalent sizes
  2. concatenate the resulting arrays into a new one
  3. get the length of this array which will be equal to the sum of numeric literals

Here’s a visual representation of this algorithm:

addition logic
Addition using arrays

Let’s start by converting numeric literals into tuples. There are multiple ways of doing that in JavaScript, but given the limitations of TS and lack of proper loops, we have to use recursion to achieve the desired result. We’ll get to a type implementation in a moment, but first, let’s look at a JavaScript code mimicking the logic we’ll implement:

const toTuple = (n: number, arr: any[] = []) => {
  if (arr.length === n) {
    return arr
  } else {
    const newTuple = [...arr, 0]
    return toTuple(n, newTuple)
  }
}

toTuple(3)    // => [0, 0, 0]

Here we start with an empty array and keep appending new items into it and calling the function recursively until we get the expected number of items in the array. The value 0 is used arbitrarily here as we don’t care about the values of array items, but only about their total count.

Now that we understand how the logic is supposed to work, we can make our ToTuple type (while making use of the Length type we created earlier):

type ToTuple<N extends number, T extends any[] = []> =
  Length<T> extends N
    ? T
    : ToTuple<N, [...T, 0]>

Next, we need a way of concatenating two tuples. This can be easily achieved by spreading both tuples into a new one:

type Concat<A extends any[], B extends any[]> = [...A, ...B]

Finally, to get the sum, we need to get the length of the resulting tuple. We already have the type that can do that, so we just need to put all the pieces together:

type Add<A extends number, B extends number> = Length<
  Concat<ToTuple<A>, ToTuple<B>>
>

type Result1 = Add<1, 1>    // => 2
type Result2 = Add<2, 5>    // => 7

Subtraction

The signature of Sub type is going to be the following:

type Sub<A extends number, B extends number> = // to be defined

type Result = Sub<6, 2>   // => 4

For this type, we’re going to rely on arrays as well, but the logic is going to be different. To subtract array B from array A, we will:

  1. check if array A includes array B
  2. if it is, infer the remaining part
  3. get the length of the remainder

And here’s a visual representation:

subtraction logic
Subtraction using arrays

We can reuse ToTuple and Length types from the previous example, so the resulting type will look like this:

type Sub<A extends number, B extends number> = ToTuple<A> extends [
  ...ToTuple<B>,
  ...infer U
]
  ? Length<U>
  : unknown

type Result1 = Sub<5, 2>  // => 3
type Result2 = Sub<3, 3>  // => 0
type Result3 = Sub<2, 5>  // => unknown

As you can see, the first two cases work as intended, but we get a problem if array B is larger than array A. Following the basic property of subtraction (2 - 5 = -(5 - 2)), the solution to this problem is for A and B to switch places, and to change the sign of the result to a negative:

type Sub<A extends number, B extends number> = ToTuple<A> extends [
  ...ToTuple<B>,
  ...infer U
]
  ? Length<U>
  : Negative<Sub<B, A>>

We don’t have Negative type yet, so let’s define it. First, we can easily append a minus sign using string interpolation:

type Negative<N extends number> = `-${N}`

type Result = Negative<5> // => "-5"

As a result, we get a string literal type, and to convert it into a number, we’ll use a new feature available in TypeScript >= 4.8, that lets us use extends constraint to infer type variables in conditional types:

type ToNumber<T> = T extends `${infer N extends number}` ? N : never

type Negative<N extends number> = ToNumber<`-${N}`>

type Result = Negative<5>  // => -5

This is incredibly powerful, as we now can check if our type T looks like a string the content of which looks like a number, and even infer and return that number.

With that in place, we can confirm that all our test cases work as intended:

type Sub<A extends number, B extends number> = ToTuple<A> extends [
  ...ToTuple<B>,
  ...infer U
]
  ? Length<U>
  : Negative<Sub<B, A>>

type Result1 = Sub<5, 2>  // => 3
type Result2 = Sub<3, 3>  // => 0
type Result3 = Sub<2, 5>  // => -3

Now that we have implemented both Add and Sub types, it’s important to acknowledge that, in addition to not having practical use, they also have some flaws. One of them is related to computational complexity - TypeScript compiler has the max call stack depth of 1000 for recursive types, so any number above that wouldn’t work. Another one is that neither of our types would work with negative numbers, as we simply wouldn’t be able to create tuples of a negative length.

Having said that, a robust production-ready implementation wasn’t our goal. Instead, we got to practice different types we learned in a creative way without getting distracted by the minutiae of specific use cases.

Conclusion

Despite the wide adoption of TypeScript these days, a lot of powerful features that the language has to offer remain relatively unknown. Such features include property accessors, as well as conditional, inferred, and recursive types. Admittedly, these types exist to solve very specific problems we don’t often face, but it’s important to have them in your arsenal anyway for when the opportunity to use them arises.


Konstantin Lebedev © 2022