Crazy, Powerful TypeScript 4.1 Features

19 November 2020

In this article I'll take a look at some new features in TypeScript 4.1 - namely Template Literal Types, Key Remapping and Recursive Conditional Types. At the end I'll show some crazy examples of using these features to do a bit of meta-programming.

New Features in TypeScript 4.1

I’ve always loved TypeScript and the language just keeps getting better with each evolution. 4.1 has lots of interesting new features. Here I'm going to look at:

  • Template Literal Types
  • Key Remapping in Mapped Types
  • Recursive Conditional Types

Template Literal Types

TypeScript has supported string literals types since 1.8.

type Beatles = "John" | "Paul" | "George" | "Ringo"

These are extremely powerful when we want to bring safety to stringly typed API’s. Literal types were later extended to support numeric, boolean and enum literals as well.

type StoryPoints = 1 | 2 | 3 | 5 | 8 | 13 | 21 | 'Infinity';

In 4.1, TypeScript now supports Template Literals as types. Where normal template literals embed expressions within a string, Template Literal Types allow types to be embedded.

type Suit =
  "Hearts" | "Diamonds" |
  "Clubs" | "Spades";

type Rank =
  "Ace" | "Two" | "Three" | "Four" | "Five" |
  "Six" | "Seven" | "Eight" | "Nine" | "Ten" |
  "Jack" | "Queen" | "King";

type Card = `${Rank} of ${Suit}`;

const validCard: Card = "Three of Hearts";
const invalidCard: Card = "Three of Heart"; // Compiler Error

The Card type will be every permutation of the Suit and Rank in the template ${Rank} of ${Suit}. As you can see this is useful for building out patterns of string types. It can also be useful when combining with other type operators such as keyof.

interface UserData {
  name: string;
  age: number;
  registered: boolean;
}

// Generates: "getName" | "getAge" | "getRegistered"
type UserDataAccessorNames = `get${Capitalize<keyof UserData>}`;

As you can see the string type has been capitalized - TypeScript 4.1 has added 4 such helpers,

  • Capitalize<T>
  • Uncapitalize<T>
  • Uppercase<T>
  • Lowercase<T>

Key Remapping in Mapped Types

We can build upon the template literal types feature by combining with mapped-types. If we think about generating a type for the accessors in the previous example we can start with a mapped type:

type UserDataAccessors = {
  [K in keyof UserData]: () => UserDataAccessors[K];
}

This creates a new type with the original data members mapped to a function returning their type:

{
  name: () => string;
  age: () => number;
  registered: () => boolean;
}

Now we need to map the keys of the type to `get${Capitalize<keyof UserData>}`. We could do this simply as [K in `get${Capitalize<keyof UserData>}`] but we need to retain the original K so we access UserDataAccessors[K]. Remapping with as allows us to map the left-hand side while still having access to the original key:

type UserDataAccessors = {
  [K in keyof UserData as `get${Capitalize<K>}`]: () => UserData[K];
}

This creates the type:

{
  getName: () => string;
  getAge: () => number;
  getRegistered: () => boolean;
}

Recursive Conditional Types

The final 4.1 feature I'm going to talk about in this post revolves around everyone's favourite software topic, Recursion.

Recursive Conditional Types are exactly what the name suggests, Conditional Types that reference themselves.

type BuildTuple<Current extends [...T[]], T, Count extends number> =
  Current["length"] extends Count
    ? Current
    : BuildTuple<[T, ...Current], T, Count>

type Tuple<T, Count extends number> = BuildTuple<[], T, Count>

This example creates a Tuple type that will generate a Tuple of a specified size. For example:

// Generates: [string, string, string, string, string]
type StringQuintuple = Tuple<string, 5>

Let's break down what's happening in this example.

type BuildTuple<Current extends [...T[]], T, Count extends number> =

We can think of this type like a recursive function with the type parameters the parameters of the function. The key thing to remember here is that we are dealing with types, not values. The Current parameter is the tuple as it is being built up, T is the type of every element in the tuple and Count is the required number of entries. Note the way Current is constrained as a variadic tuple type.

type BuildTuple<Current extends [...T[]], T, Count extends number> =
  Current["length"] extends Count
    ? Current
    : BuildTuple<[T, ...Current], T, Count>

So if the Current tuple is the correct 'length', we are finished and evaluate as Current. Otherwise we invoke the type again with an extended tuple. Again, note how the new Current is generated by using the spread operation on the previous Current - [T, ...Current].

We already had recursive types in TypeScript but this version allows us to use them directly in conditional types.

Crazy Examples

If you'd like to see some fun examples of this, I've created a couple of repositories on GitHub. These examples 'compute' a type that is the solution to a problem. Note that the compiler will generate errors as there's too much recursion going on, but this is just a bit of mad fun.

Sudoku

https://github.com/eamonnboyle/sudoku-type-solver

Here we have code that generates a type that solves a Sudoku puzzle.

Sudoku Type Solver

The example uses the string literal types "true" and "false" to create generic types that act as pseudo conditional functions.

type FilterSet<C extends Numbers, F extends Numbers> =
  IsSingleNumber<C> extends "true" ? C : Exclude<C, F>;

I haven't used the booleans true and false as the union true | false gets simplified to boolean and I want the distinct values.

The main solver type uses recursion:

export type SolveGame<G extends SudokuGame> =
  IsGameComplete<G> extends "true" 
    ? G 
    : SolveGame< SolverIteration<G> >;

12 Days of Christmas

https://github.com/eamonnboyle/12-days-of-christmas-type-solver

This project generates the lyrics of the classic holiday song, The 12 Days of Christmas.

12 Days of Christmas Type Solver

It makes heavy use of string literals including template literal types:

const DaysTuple = [
    'Twelfth', 'Eleventh', 'Tenth', 'Ninth', 'Eighth', 'Seventh', 
    'Sixth', 'Fifth', 'Fourth', 'Third', 'Second', 'First',
] as const;
type DaysTupleType = typeof DaysTuple
type Days = DaysTupleType[number];

type FirstLine<D extends Days> = `On the ${D} day of Christmas my true love sent to me,`

Note how the Days union is created, using a const assertion. This can sometimes be useful when you want to have a single source of truth for multiple entities such as data, a tuple type and a union type.

The main solver again uses recursion:

export type TwelveDaysOfChristmas<
  D extends readonly [...Days[]] = DaysTupleType,
  G extends [...Gifts[]] = GiftsTupleType
> =
    D["length"] extends 0
    ? []
    : [
        ...TwelveDaysOfChristmas<Tail<D>, Tail<G>>, 
        DayVerse<D[0], GiftsForDay<D, G>>
      ]

Conclusion

I hope that this article gets you excited about TypeScript and some of its more advanced type features.

Checkout our TypeScript course. We're also happy to deliver React training using TypeScript.
We deliver virtually to companies all over the world and are happy to customise our courses to tailor to your team's level and specific needs. Come and check us out to see if we can help you and your team.

Article By
blog author

Eamonn Boyle

Instructor, developer