An introduction to type programming in TypeScript
Zhenghao He
Posted on February 6, 2022
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.
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:
- use recursion instead of iteration
- in TypeScript 4.5 we have tail call optimized recursion to some extent
- 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'}
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}
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
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`
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
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
}
Translate it into the type language:
type Username = 'foo'
type Matched = Username extends 'foo' ? true : false // true
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
}
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']
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]
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"]
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"]
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'}
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; }
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'}
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 }
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'
The equivalence in our type language:
type Str = 'foo-bar'
type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar'
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;
}
The recurisve solution would be:
function fillArray(item, n, array = []) {
return array.length === n ? array : fillArray(item, n, [item, ...array])
}
How do we write out the equivalence in our type language? Here are logical steps to arrive at one solution:
- 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[] = []>
- Inside the "function body", we need to check if the
length
property onArray
is alreadyN
using theextends
keyword.- if it has reached to
N
(the base case), then we simply returnArray
- if it hasn't reached to
N
, it recurses and added one moreItem
intoArray
- if it has reached to
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"]
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:
They are more like academic exercises, not suitable for production applications because:
- they are hard to comprehend, especially with esoteric TypeScript features.
- they are hard to debug due to incredibly long and cryptic compiler error messages.
- 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
Posted on February 6, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024