One Hour TypeScript Workshop ⏱️
— by Gabriel Vergnaud · May 23, 2023
Let's play with types
TypeScript has one of the most amazing type systems out there. It might feel counterintuitive, but TypeScript types alone are a Turing-complete programming language! The art of writing algorithms in the language of types is called type-level programming.
In this one-hour workshop, we are going to discover the world of type-level programming by focusing on its similarities with a language you probably know inside out: JavaScript. We'll avoid all the theory and skip straight to solving fun challenges! 🎉
Each section will introduce a new concept that will move us one step closer to the final boss of this workshop 🤖: Building a type-level query parser!
No prior type-level programming experience is assumed. We are going to go from zero to advanced type-level programming in a single hour.
Let's go! 🎮
Introduction — A data-fetching story
Let's say we are building an app displaying tables of data. To fetch this data, we use the following query syntax:
const query = `system.cpu.user{env:prod} by {datacenter,service}`;
/* 👆 👆 👆
Metric name filter groupings */
- The metric name defines the data points we are interested in.
- The filter part consists of one or several
key:value
tags. Rows without these tags will be filtered out. - The groupings part consists of a list of comma-separated tag keys. It defines how the data should be split.
We pass these queries to a fetchTable
function we wrote, which makes an HTTP request to some server and returns an array of table columns:
const columns = await fetchTable(query);
Here is the table we got this time:
| datacenter | service | system.cpu.user{env:prod} |
| ----------- | ------- | ------------------------- |
| us-east | web | 42 % |
| us-east | cron | 9 % |
| europe-west | web | 43 % |
| europe-west | cron | 12 % |
Looks like we have two data centers and two services, and for each combination we get the average CPU they consume in percent. As you can see, the number and types of the resulting columns
entirely depend on the query:
- For each grouping, we get a column containing strings.
- The last column always contains our numeric values.
In TypeScript, we could represent this table using the following type:
type Column<Name, Value> = { name: Name; values: Value[] };
declare const columns: [
Column<"datacenter", string>,
Column<"service", string>,
Column<"system.cpu.user{env:prod}", number>,
];
This type is great because it is very precise. It tells us we have exactly 3 columns and the column's name and type at each index. ✨ How awesome would it be if our fetchTable
function could return a type as precise as this one? ✨
But here is the problem: what if we wanted to fetch data for a different query?
The type signature of our fetchTable
function would surely need to somehow infer its return type from the query we provide! We might, for example, want to fetch how many times each of our React components have been mounted:
export declare function fetchTable<Q extends string>(
query: Q,
): Promise<TableFromQuery<Q>>;
/**
* This is regular function composition!
*
* 1. We get the groups as a comma-separated string with `GetGroups`
* 2. We Split this string in commas, using the `Split` function
* 3. We map over this array of groups to turn them into
* columns with `MapGroupsToColumns`.
* 4. We add the value column to the array with `GetMetric` and `Append`
*/
type TableFromQuery<Q> = Append<
MapGroupsToColumns<Split<GetGroups<Q>, ",">>,
Column<GetMetric<Q>, number>
>;
/**
* This is a recursive map function that puts each
* `Group` string into a `{ name, values }` object.
*/
type MapGroupsToColumns<Groups> = Groups extends [infer Group, ...infer Rest]
? [Column<Group, string>, ...MapGroupsToColumns<Rest>]
: [];
type Column<Name, Value> = { name: Name; values: Value[] };
/**
* Helper functions we already know:
*/
type Append<Tuple extends any[], Element> = [...Tuple, Element];
type GetMetric<Q> = Q extends `${infer Metric} by {${string}}` ? Metric : never;
type GetGroups<Q> = Q extends `${string} by {${infer Groups}}` ? Groups : never;
type Split<
Str,
Sep extends string,
> = Str extends `${infer First}${Sep}${infer Rest}`
? [First, ...Split<Rest, Sep>]
: [Str];
export declare function fetchTable<Q extends string>(
query: Q,
): Promise<TableFromQuery<Q>>;
/**
* This is regular function composition!
*
* 1. We get the groups as a comma-separated string with `GetGroups`
* 2. We Split this string in commas, using the `Split` function
* 3. We map over this array of groups to turn them into
* columns with `MapGroupsToColumns`.
* 4. We add the value column to the array with `GetMetric` and `Append`
*/
type TableFromQuery<Q> = Append<
MapGroupsToColumns<Split<GetGroups<Q>, ",">>,
Column<GetMetric<Q>, number>
>;
/**
* This is a recursive map function that puts each
* `Group` string into a `{ name, values }` object.
*/
type MapGroupsToColumns<Groups> = Groups extends [infer Group, ...infer Rest]
? [Column<Group, string>, ...MapGroupsToColumns<Rest>]
: [];
type Column<Name, Value> = { name: Name; values: Value[] };
/**
* Helper functions we already know:
*/
type Append<Tuple extends any[], Element> = [...Tuple, Element];
type GetMetric<Q> = Q extends `${infer Metric} by {${string}}` ? Metric : never;
type GetGroups<Q> = Q extends `${string} by {${infer Groups}}` ? Groups : never;
type Split<
Str,
Sep extends string,
> = Str extends `${infer First}${Sep}${infer Rest}`
? [First, ...Split<Rest, Sep>]
: [Str];
import { fetchTable } from "./fetchTable";
const cols1 = fetchTable("react.mount{*} by {component}");
/* ^? Promise<[
Column<"component", string>,
Column<"react.mount{*}", number>
]> */
import { fetchTable } from "./fetchTable";
const cols1 = fetchTable("react.mount{*} by {component}");
/* ^? Promise<[
Column<"component", string>,
Column<"react.mount{*}", number>
]> */
Or the duration each render takes, split by component, page and env:
import { fetchTable } from "./fetchTable";
const cols2 = fetchTable("render.duration{*} by {component,page,env}");
/* ^? Promise<[
Column<"component", string>,
Column<"page", string>,
Column<"env", string>,
Column<render.duration{*}, number>
]> */
import { fetchTable } from "./fetchTable";
const cols2 = fetchTable("render.duration{*} by {component,page,env}");
/* ^? Promise<[
Column<"component", string>,
Column<"page", string>,
Column<"env", string>,
Column<render.duration{*}, number>
]> */
💡 Hover over variable names to see type inference in action.
But how do we write such function signatures?
Well, this is where type-level programming comes into play! The goal of this workshop is to gradually learn how to write functions with a smart type inference logic. We will do so by solving type challenges for each concept we need to know, and gradually move towards the final boss: typing fetchTable
.
How to solve challenges
Here is a first type challenge. Let's see if you can solve it!
Congratulation! 🎉
namespaces
are a lesser-known TypeScript feature which lets us isolate each challenge in a dedicated scope.TODO
is a placeholder. This is what you need to replace!type test1 = Expect<Equal<T, ...>>
is a type-level unit test. It won't type-check until you find the correct solution.
I sometimes use @ts-expect-error
comments to make sure invalid inputs are rejected by the type-checker. This line will only type check if the next line does not!
// @ts-expect-error ✅ this type-checks because
let x: number = "Hello"; // this line does not.
// @ts-expect-error ❌ this doesn't type-check because
let y: number = 2; // this line does!
// @ts-expect-error ✅ this type-checks because
let x: number = "Hello"; // this line does not.
// @ts-expect-error ❌ this doesn't type-check because
let y: number = 2; // this line does!
Let's get started!
1. Type Parameters
To start writing type-level functions, we first need some inputs! We are going to lift information from the value level to the type level using type inference. There are several ways to make TypeScript infer types from values, but in this workshop, we will be using type parameters.
Useful links 📚
Good job! 🎉
2. Type-Level Arrays
The arrays of type-level programs are called tuples. They are types representing arrays with a fixed length, and each index can contain a value of a different type.
type Empty = [];
type One = [1];
type Two = [1, "2"]; // types can be different!
type Three = [number, string, number]; // tuples can contain duplicates
Let's implement the type-level version of some of the most common array utilities!
Useful links 📚
🔥 Awesome work!
3. Type-Level Strings
In JavaScript, we use template literals to concatenate strings all the time:
const firstName = "Albert";
const lastName = "Einstein";
const name = `${firstName} ${lastName}`; // <- template literal
// => "Albert Einstein"
But did you know it also works with types? Let's play with Template Literal Types, a super powerful feature that's unique to TypeScript's type system.
Useful links 📚
You did it! 💪
4. Code branching
Code branching is the most basic building block of algorithmic logic. At the type level, we write branching logic using Conditional Types, which do look a lot like JavaScript's ternaries:
type Maybe = A extends B ? "yes" : "no";
/* ----------- ---- -----
^ / \
"is A assignable branch branch
to B ?" if true if false
*/
Thanks to the infer
keyword though, Conditional Types are much more powerful than they seem. You can use them to perform type-level destructuring:
type GetName<User> = User extends { name: infer Name } ? Name : "Anonymous";
// 👆
// `infer` declares a variable called `Name`.
type N = GetName<{ name: "Gabriel" }>; // "Gabriel"
We use infer
to extract the "name" property of User
, and we return its content. Here is an equivalent piece of code written in Javascript.
const getName = ({ name }) => name;
getName({ name: "Gabriel" }); // "Gabriel"
Let's see what else we can do with Conditional Types!
Useful links 📚
Hats off 🎩
5. Loops
Type-Level TypeScript being a functional language, it doesn't have a for
or a while
statement. Instead, we will use recursion:
type DoRepetitiveTask<SomeInput> = Condition<SomeInput> extends true
/* 👆
Should we keep looping?
RECURSION!
👇 */
? DoRepetitiveTask<Transform<SomeInput>>
/* 👆
Update the input for the next iteration.
*/
: Otherwise<SomeInput>;
/* 👆
Compute the final return type.
*/
Notice that we did not need any new syntax to write a loop. A conditional type is all we need to stop the recursion from looping indefinitely.
Let's start creating our first non-trivial type-level algorithms! 🎢
Useful links 📚
🤩 Impressive!
Final Boss 🤖
You've reached the final exercise of this workshop. Congrats! 🎉
Now, your goal is to decompose a more challenging problem into subproblems, and use function composition to solve it. Good luck!
🤯 You're a beast!
Conclusion
That's already the end of this workshop! In only a handful of challenges, we moved from basic type transformations to solving real-world problems. Congratulations if you managed to solve them all!
As you've noticed by now, types are much more than simple annotations. It's a full-fledged programming language, with a lot of concepts we already know: data structures, functions, code branching, and so much more.
While it's fun to skip the theory and play with code right away, I can't encourage you enough to go through the Type-Level TypeScript course. You will build a robust mental model of assignability, and never feel like the type system restrains your ability to write the abstractions you need again.
If you liked this article, chances are you'll like Type-Level Typescript. It's an advanced TypeScript course that will give you a solid understanding of the type system's fundamentals and guide you through its most advanced features. Enroll Now!
Subscribe to the newsletter!
Receive all new chapters and articles from Type-Level TypeScript directly in your inbox!