A different view of TypeScript's type system

What if types were values? Could we simplify our interpretation of generic types?

by François Wouts

In my quest for designing a universal representation of code across all programming languages, I've been trying to build a deeper understanding of advanced language features. One feature that stands out is TypeScript's advanced type system, which allows types to be computed from other types. First, let's go through a quick intro, then we'll see how it could be interpreted in a different way.

Introduction to TypeScript

TypeScript is a powerful type system for JavaScript. It lets you catch issues early in the development process, such as accessing the wrong property of an object or passing an invalid function argument:

type User = {
  name: string,
  login: string,
  age: number,
  comments: Comment[]
};

function greet(user: User) {
  alert(`Hi ${user.firstName}!`);
  // ⛔️ Property 'firstName' does not exist on type 'User'.
}

greet("Bob");
// ⛔️ Argument of type 'string' is not assignable to parameter of type 'User'

TypeScript offers advanced features such as string literal types and unions. Here is an example in action:

type Color = 'white' | 'red' | 'green' | 'yellow' | 'blue' | 'black';

function fill(color: Color) {
  // ...
}

fill('green');
// ✅

fill('rainbow');
// ⛔️ Argument of type '"rainbow"' is not assignable to parameter of type 'Color'.

Generic types in TypeScript

What makes TypeScript particularly useful is its support for generic types, which let you manipulate and transform types.

Here is one example with the generic type Pick from the official docs. Pick allows you to define a type that has specific keys in common with another type. This can be useful when you want to make sure that two types stay consistent with each other:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type User = {
  name: string,
  login: string,
  age: number,
  comments: Comment[]
};

type UserPreview = Pick<User, 'name' | 'age'>;

// equivalent to defining:
type UserPreview = {
  name: string,
  age: number
};

Explaining the Pick generic type

TypeScript's type manipulation syntax can be quite difficult to grasp. If you're struggling to understand what Pick does, you're not alone.

Let's decompose it to understand exactly what it does:

  1. Pick<T, K> means that Pick expects two type parameters which we name T and K. We could just as well have named them Foo and Bar if we wanted to. Using single-letter type names is only a convention.
  2. K extends keyof T means that a type passed as K must be a subset of the type of keys of T (see generic constraints).
  3. [P in K]: X means that for each possible value P in K, we declare a property P of type X (see mapped types).
  4. T[P] means that we look up the type of the property P of type T (see indexed access types).

In our example, we invoked Pick<User, 'name' | 'age'> which means:

T = User
K = 'name' | 'age'
keyof T = 'name' | 'login' | 'age' | 'comments'

K is indeed a subset of keyof T, so the condition specified in (2) is satisfied.

The possible values of P are 'name' and 'age' so:

type UserPreview = {
  ['name']: User['name'],
  ['age']: User['age']
};

Since User['name'] is string and User['age'] is number, the resulting type is indeed:

type UserPreview = {
  ['name']: string,
  ['age']: number
};

This is equivalent to what we had earlier:

type UserPreview = {
  name: string,
  age: number
};

The Return generic type

Here is another example. Return<T> lets you extract the return type of a function:

type Return<T> = T extends (...args: any[]) => infer R ? R : never;

Let's decompose this one:

  • Return<T> means that it expects a single type parameter.
  • T extends X ? A : B means that that if T "extends" X, the generic type will return A, otherwise B (see conditional type).
  • (...args: any[]) => infer R represents any function (any number of arguments of any type). The infer keyword here allows us to refer to the function's return type as R (see inferred types).
  • never is a type of values that never occur. There are no possible values of type never, not even null or undefined (see basic types).

If we pass it a function type, we'll get its return type:

function f() {
  return 123;
}

type Example = Return<typeof f>;

// equivalent to defining:
type Example = number;

On the other hand if we pass it a type that doesn't represent a function, we'll get never:

type Example = Return<number>;  // never

The examples we've just seen are, perhaps surprisingly, fairly simple generic types. It can get complicated quite quickly, to the point where TypeScript challenges have emerged. In fact, TypeScript's type system is powerful enough to be Turing Complete.

A different perspective

As part of my research into Graphene, I started exploring the meaning of generic types a little deeper. One particular analogy I wanted to explore was the difference between functions and generic types. Can we see types as values, and generic types as type-level functions?

Types as values

First, it's worth noting that a type definition looks a lot like a value. Notice the similarity between:

A variable assignment in JavaScript

let user = {
  name: "Bob",
  login: "robert",
  age: 30,
  comments: []
}

A corresponding type declaration in TypeScript

type User = {
  name: string,
  login: string,
  age: number,
  comments: Comment[]
};

We can think of User as a value, which is composed of other values such as string, number and Comment[]. They all belong to the same "space of types". Unlike JavaScript values, which exist at runtime, type values exist at compile time.

Generic types as functions

Next, let's look at generic types. Could we think of them as functions?

Pick<T, K> as a function

Let's look at our Pick example from earlier:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

If we consider types to be values, we could actually represent Pick as a function. Here is what that could look like, inspired from JavaScript's function syntax:

type function Pick(T, K) {
  if (!K.extends(keyof(T))) {
    throw new Error("K does not extend keyof T");
  }
  return {
    ...K.map(P => property(P, T.getPropertyType(P)));
  };
}

Even better, we could get rid of the clumsy throw statement and use type annotations to describe the types T and K:

type function Pick(T: AnyType, K: keyof T) {
  return {
    ...K.map(P => property(P, T.getPropertyType(P)));
  };
}

Let's look at an invocation of our Pick function:

Pick(User, 'name' | 'age')

// ⬇️ interpreted as ⬇️

{
  ...('name' | 'age').map(P => property(P, User.getPropertyType(P)))
}

// ⬇️ meaning ⬇️

{
  name: User['name'],
  age: User['age']
}

// ⬇️ further simplified to ⬇️

{
  name: string,
  age: number
}

This produces exactly the type we expected.

Return<T> as a function

Now let's look at our Return type:

type Return<T> = T extends (...args: any[]) => infer R ? R : never;

Again, we can rewrite it as a function:

type function Return(T: AnyType) {
  return T.isFunctionType() && T.getArgumentTypes().extends(AnyType[])
    ? T.getReturnType()
  	: never;
}

In fact, since we don't care about argument types, we can write the more elegant:

type function Return(T: AnyType) {
  return T.isFunctionType() ? T.getReturnType() : never;
}

Recursion and types

One potentially challenging aspect of types is that they can be recursive. Here is a LinkedList type:

type LinkedList = {
  value: any,
  next: LinkedList | null;
}

The equivalent value would be illegal in JavaScript:

const LinkedList = {
  value: 'any',
  next: or(LinkedList, null)
};
// ⛔️ Block-scoped variable 'LinkedList' used before its declaration.

However, the only reason why this is illegal is because JavaScript is eagerly evaluated. That is, a value is evaluated as soon as it's expressed.

Types are different. It is perfectly fine for a type to be self-referential in TypeScript. You could say that types are lazily evaluated. In fact, you'll find that our "type functions" have no mutable variables or side-effects, i.e. they are pure functions. We could think of the TypeScript type system as a purely functional, lazy-evaluated language!

Final words

Let's summarise our findings:

  • Types can be thought of as abstract values and generic types as pure functions on these values.
  • The TypeScript type system is its own lazily evaluated language.
  • We've come up with an alternative syntax for generic types, using type function.

TypeScript's generic types are well know for their fairly unreadable syntax. I wonder if we could leverage these insights to make TypeScript more readable, and perhaps unlock even more powerful generic types?

If you're curious to read more insights on computer science and software engineering, I'm working on a book that explores the similarities between programming languages and builds up a universal model of code. Subscribe to my newsletter below to get free access when it's available 🙂

Read other articlesWho's François?