DEV Community

Zhenghao He
Zhenghao He

Posted on • Originally published at zhenghao.io on

An introduction to type programming in TypeScript

See discussions on Hacker News

Types are a complex language of their own

I used to think of TypeScript as just JavaScript with type annotations sprinkled on top of it. With that mindset, I often found writing correct types tricky and daunting, to a point they got in the way of building the actual applications I wanted to build, and frequently, it led me to reach for any. And with any, I lose all type safety.

Indeed, types can get really complicated if you let them. After writing TypeScript for a while, it occurred to me that the TypeScript language actually consists of two sub-languages - one is JavaScript, and the other is the type language. For the JavaScript language, the world is made of JavaScript values; for the type language, the world is made of types. When we write TypeScript code, we are constantly dancing between these two worlds: we create types in our type world and "summon" them in our JavaScript world using type annotations; we can go in the other direction too: use the typeof operator on JavaScript variables/properties to retrieve the corresponding types.

alt

The JavaScript language is very expressive, so is the type language - in fact, the type language is so expressive that it has been proven to be Turing complete.

Here I don't make any value judgment of whether being Turing complete is good or bad, nor do I know if it is even by design or by accident (in fact, often times, Turing-completeness was achieved by accident). My point is the type language itself, as innocuous as it seems, is certainly powerful, highly capable and can perform arbitrary computation at compile time.

When I started to think of the type language in TypeScript as a full-fledged programming language, I realized it even has a few characteristics of a functional programming language:

  1. use recursion instead of iteration
    1. in TypeScript 4.5 we have tail call optimized recursion to some extent
  2. types (data) are immutable

In this post, we will learn the type language in TypeScript by comparing it with JavaScript so that you can leverage your existing JavaScript knowledge to master TypeScript quicker.

This post assumes that readers have some familiarity with JavaScript and TypeScript. And if you want to learn TypeScript from scratch properly, you should start with The TypeScript Handbook. I am not here to compete with the docs.

Variable declaration

In JavaScript, the world is made of JavaScript values, and we declare variables to refer to values using keywords var, const and let. For example:

const obj = {name: 'foo'}
Enter fullscreen mode Exit fullscreen mode

In the type language, the world is made of types, and we declare type variables using keywords type and interface. For example:

type Obj = {name: string}
Enter fullscreen mode Exit fullscreen mode

The idea of "type variables" is a made-up concept - a type variable is an alias of a type, analogous to how a JavaScript variable references a value. I found drawing this analogy makes explaining concepts of the type language much easier.

Types and values are very related. A type, at its core, represents the set of possible values. Sometimes the set is finite, e.g., type Name = 'foo' | 'bar', a lot of times the set is infinite, e.g., type Age = number. In TypeScript we integrate types and values and make them work together to ensure that the runtime values match the compile-time types.

Local variable declaration

We talked about how you can create type variables in the type language. However, the type variables have a global scope by default. To create a local type variable, we can use the infer keyword in our type language.

type A = 'foo'; // global scope
type B = A extends infer C ? (
    C extends 'foo' ? true : false// *only* inside this expression, C represents A
) : never
Enter fullscreen mode Exit fullscreen mode

Although this particular way of creating scoped variables might seem strange to JavaScript developers, it actually finds its roots in some pure functional programming languages. For example, in Haskell, we can use the let keyword with in to perform scoped assignments as in let {assignments} in {expression}:

let two = 2; three = 3 in two * three 
//                                
// two and three are only in scope for the expression `two * three` 
Enter fullscreen mode Exit fullscreen mode

Equality comparisons and conditional branching

In JavaScript. we can use ===/== with if statement or the conditional (ternary) operator ? to perform equality check and conditional branching.

In the type language, on the other hand, we use the extends keyword for "equality check", and the conditional (ternary) operator ? for conditional branching too as in:

    TypeC = TypeA extends TypeB ? TrueExpression : FalseExpression
Enter fullscreen mode Exit fullscreen mode

If TypeA is assignable or substitutable to TypeB, then we enter the first branch and get the type from TrueExpression and assign that to TypeC ; otherwise we get the type from FalseExpression as a result to TypeC.

The concept of assignability/substitutability is one of the core concepts in TypeScript that deserves a separate post - I wrote one covering that in detail.

A concrete example in JavaScript:

const username = 'foo'
let matched

if(username === 'foo') {
    matched = true
} else {
    matched = false
}
Enter fullscreen mode Exit fullscreen mode

Translate it into the type language:

type Username = 'foo'
type Matched = Username extends 'foo' ? true : false // true
Enter fullscreen mode Exit fullscreen mode

The extends keyword is versatile. It can also apply constraints to generic type parameters. For example:

function getUserName<T extends {name: string}>(user: T) {
    return user.name
}
Enter fullscreen mode Exit fullscreen mode

By adding the generic constraints, <T extends {name: string}> we ensure the parameter our function takes always consist of a name property of the type string.

Retrieve types of properties by indexing into object types

In JavaScript we can access object properties with square brackets e.g. obj['prop'] or the dot operator e.g., obj.prop.

In the type language, we can extract property types with square brackets as well.

type User = {name: string, age: number}
type Name = User['name']
Enter fullscreen mode Exit fullscreen mode

This works not just with object types, we can also index the type with tuples and arrays.

type Names = string[]
type Name = Names[number]

type Tuple = [string, number]
type Age = Tuple[1]
Enter fullscreen mode Exit fullscreen mode

Functions

Functions are the main reusable “building blocks” of any JavaScript program. They take some input (some JavaScript values) and return an output (also some JavaScript values).
In the type language, we have generics. Generics parameterize types like functions parameterize value. Therefore, a generic is conceptually similar to a function in JavaScript.

For example, in JavaScript:

function fn(a, b = 'world') { return [a, b] }
const result = fn('hello') // ["hello", "world"]
Enter fullscreen mode Exit fullscreen mode

For our type language, we have:

type Fn  <A extends string, B extends string = 'world'>   =  [A, B]
//   ↑    ↑           ↑                          ↑              ↑
// name parameter parameter type          default value   function body

type Result = Fn<'hello'> // ["hello", "world"]
Enter fullscreen mode Exit fullscreen mode

However, generics are by no means a perfect analogy for functions. For one, unlike functions in JavaScript, Generics are not first-class citizens in the type language. That means we cannot pass a generic to another generic like we pass a function to another function as TypeScript doesn't allow generics as type parameters.

Map and filter

In our type language, types are immutable. If we want to modify a part of a type, we have to transform the existing ones into new types. In the type language, the details of iterating over a data structure (i.e. an object type) and applying transformations evenly are abstracted away by Mapped Types. We can use it to implement operations that are conceptually similar to the map and filter array methods in JavaScript.

In JavaScript, let's say we want to transform an object's properties from numbers to strings:

const user = {
    name: 'foo',
    age: 28
}

function stringifyProp(object) {
    return Object.fromEntries(Object.entries(object)
        .map(([key, value]) => [key, String(value)]))
}

const userWithStringProps = stringifyProp(user) // {name:'foo', age: '28'}
Enter fullscreen mode Exit fullscreen mode

In the type langauge, the mapping is done using this syntax [K in keyof T] where the keyof operator gives us property names as a string union type.

type User = {
    name: string,
    age: number
}

type StringifyProp<T> = {
    [K in keyof T]: string
}

type UserWithStringProps = StringifyProp<User> // { name: string; age: string; }
Enter fullscreen mode Exit fullscreen mode

In JavaScript, we can filter out the properties of an object based on some critiria. For example, we can filter out all non-string properties:

const user = {
    name: 'foo',
    age: 28
}

function filterNonStringProp(object) {
    return Object.fromEntries(Object.entries(object)
        .filter(([key, value]) => typeof value === 'string' && [key, value]))
}

const filteredUser = filterNonStringProp(user) // {name: 'foo'}
Enter fullscreen mode Exit fullscreen mode

In our type language, this can be achieved with the as operator and the never type:

type User = {
    name: string,
    age: number
}

type FilterStringProp<T> = {
    [K in keyof T as T[K] extends string ? K : never]: string
}

type FilteredUser = FilterStringProp<User> // { name: string }
Enter fullscreen mode Exit fullscreen mode

There are a bunch of builtin utility “functions” (generics) for transforming types in TypeScript so often times you don't have to re-invent the wheels.

Pattern matching

We can also use the infer keyword to perform pattern matching in the type language.

For example, in a JavaScript program, we can use regex to extract a part of a string:

const str = 'foo-bar'.replace(/foo-*/, '')
console.log(str) // 'bar'
Enter fullscreen mode Exit fullscreen mode

The equivalence in our type language:

type Str = 'foo-bar'
type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar'
Enter fullscreen mode Exit fullscreen mode

Recursion, instead of iteration

Just like many pure functional programming languages out there, in our type language, there is no syntactical construct for for loop to iterate over a list of data. Recursion take the place of loops.

Let's say in JavaScript, we want to write a function to return an array with same item repeated multiple times. Here is one possible way you can do that:

function fillArray(item, n) {
    const res = [];
    for (let i = 0; i < n; i++) {
        res[i] = item;
    }
    return res;
}
Enter fullscreen mode Exit fullscreen mode

The recurisve solution would be:

function fillArray(item, n, array = []) {
    return array.length === n ? array : fillArray(item, n, [item, ...array])
}
Enter fullscreen mode Exit fullscreen mode

How do we write out the equivalence in our type language? Here are logical steps to arrive at one solution:

  1. create a generic type called FillArray (remember we talked about that generics in our type language are just like functions?)
    • FillArray<Item, N extends number, Array extends Item[] = []>
  2. Inside the "function body", we need to check if the length property on Array is already N using the extends keyword.
    • if it has reached to N (the base case), then we simply return Array
    • if it hasn't reached to N, it recurses and added one more Item into Array

Putting these together, we have:

type FillArray<Item, N extends number, Array extends Item[] = []> 
    = Array['length'] extends N 
        ? Array : FillArray<Item, N, [...Array, Item]>;

type Foos = FillArray<'foo', 3> // ["foo", "foo", "foo"]        
Enter fullscreen mode Exit fullscreen mode

Limits for recursion depth

Before TypeScript 4.5, the max recursion depth is 45. In TypeScript 4.5, we have tail call optimization, and the limit increased to 999.

Avoid type gymnastics in production code

Sometimes type programming is jokingly referred to as “type gymnastics” when it gets really complex, fancy and far more sophisticated than it needs to be in a typical application.
For example:

  1. simulating a Chinese chess (象棋)
  2. simulating a Tic Tac Toe game
  3. implementing arithmetic

They are more like academic exercises, not suitable for production applications because:

  1. they are hard to comprehend, especially with esoteric TypeScript features.
  2. they are hard to debug due to incredibly long and cryptic compiler error messages.
  3. they are slow to compile.

Just like we have Leetcode for practicing your core programming skills, we have type-challenges for practicing your type programming skills.

Closing thoughts

We have covered a lot in this blog post. The point of this post is not to really teach you TypeScript, rather than to reintroduce the "hidden" type language you might have overlooked ever since you started learning TypeScript.

Type programming is a niche and underdiscussed topic in the TypeScript community, and I don't think there is anything wrong with that - because ultimately adding types is just a means to an end, the end being writing more dependable web applications in JavaScript. Therefore, to me it is totally understandable that people don't often take the time to "properly" study the type language as they would for JavaScript or other programming languages.

Further Reading

Top comments (0)