TypeScript is Cool, Actually

I will take no more of this slander

Posted 15 Aug 2024


TypeScript is an underrated programming language. TypeScript is unique by virtue of being a language within a language. While TypeScript is described as a superset of JavaScript, if we look at what the compiler sees, it is perhaps better narrowed to the code that sit between the JavaScript. Through this perspective, TypeScript is interesting insofar as it has no runtime effect whatsoever, with a few minor exceptions.

When you write TypeScript, you’re not merely decorating JavaScript – although it may feel that way. You are very much writing another programming language in parallel: TypeScript is, in fact, a turing complete language.

However, most developers who write TypeScript code stop at the variable-level, feeling satisfied without knowing iteration, comparisons or sub-routines, all of which TypeScript offers.

If you’re reading this article, you’ve probably stumbled across snippets of unintelligible and eye-glazing TS code. By the end of this article, you should be able to make better sense of what’s going on and even – perhaps – feel sufficiently undaunted to produce your own.

What Stops People from Going Deeper?

Since TypeScript vanishes at runtime, it has no effect on runtime behaviour, as such. Instead, it augments the software development process. As evidenced by TypeScript’s dominance of the JavaScript ecosystem, it is apparently helpful. By changing the development process, however, it does have an effect on the runtime behaviour, if only indirectly. Bugs that would have run are caught early. Refactoring is less daunting and risky. Tracing symbols throughout the codebase and learning foreign APIs is significantly easier and less brittle.

Despite all of this, there is a common attitude that learning and writing anything but the most rudimentary TypeScript types is pointless, if not masturbatory. Like any language, writing esoteric and illegible code should certainly be avoided. But, for some reason, most of TypeScript’s surface area has been deemed esoteric enough to outright avoid. Navigating hundred-LOC types within a CRUD app is likely overkill. But there is a vast feature set of functionality in between these two extremes that goes, sadly, untouched.

The TS docs can also do a disservice to properly communicating how, where and what it might be useful for. The purpose of TS is also, often, sufficiently vague or difficult to communicate, which makes this an inherently difficult task.

In short, because TypeScript “does nothing”, there is less incentive to learn it, regardless of the actual payoff. Even with sufficient motivation, going deeper isn’t a straightforward journey. Here, we will make this journey slightly better-trodden.

The TypeScript Mental Model

Whereas traditional type-systems are baked into the underlying language, and therefore required, TypeScript is optional and distinct from the world of JavaScript. This paradigm has interestingly results in two programming paradigms being run in parallel: the world of values and the world of types. These two worlds are actually decoupled, which means the specified type may not actually match the underlying value whatsoever. This obviously has issues, but also invites many powerful – and at least interesting – features, resulting in perhaps the most capable type system ever devised. Moreover, in the same way we can write JavaScript without any TypeScript parts, we can also write “programs” entirely in TypeScript’s type system.

For authors of libraries with complex types, this distinction between values and types has already become all too clear. It’s not uncommon for a codebase to have more energy invested in the types than actual runtime code. In these instances, the chasm between value and type is self-evident.

The fact that TypeScript is immediately discarded from your compiled code feels disparaging. However, if we view it in another light, it makes more sense and further reiterates that the type system is not just a programming language due to some technicality. While our JavaScript code is executed at runtime, our TypeScript code is continually executed by our IDE language servers and during TypeScript compilation. TypeScript has the equivalent of loops, variable assignment, equality comparison and I/O. However instead of for..of and console.log, we might have mapped types or recursion, intellisense and compilation errors. You can even write tests for types (and people routinely do, myself included).

TypeScript is structural, not nominal like most type systems used in the wild. Consequently, TypeScript utilises many ideas from set theory. In other words, any given type is a set, and other types may be a subset, superset or disjoint set of that set. This is why TypeScript has “intersection”s (&) and “union”s (|), for example.

TypeScript is also a highly functional language. Types themselves cannot be mutated. So in addition to advanced TypeScript eventually leading you into ideas and terminology from set theory, it also leads you into functional programming concepts. In particular, recursion and accumulation are common patterns utilised by more demanding types.

When we view TypeScript as a programming language with sophisticated features and code that is truly executed somewhere, and with side-effects (just not during runtime), we can begin to appreciate what’s interesting about TypeScript, but more importantly how we might effectively harness it’s power.

Exploration

Types as Functions

While we typically assign types to values, when we consider how types are evaluated, it can be more helpful to view them as lambda functions in the eyes of TS. If we were to hypothetically convert our TypeScript code into JavaScript code, it might look like the following.

type type Foo = 1Foo = 1;

// Equivalent Javascript
const const foo: () => numberfoo = () => 1;

When we assign this Foo type to something, it would evaluate to something real, namely 1. That something real can then be evaluated and tested, producing compilation errors and intellisense tips.

// Evaluates to:
// const value: 1 = 2
const value: type Foo = 1Foo = 2;
Type '2' is not assignable to type '1'.

This intuition might feel contrived with simple types, but makes more sense when we consider generics.

// Generics
type type Bar<T> = TBar<function (type parameter) T in type Bar<T>T> = function (type parameter) T in type Bar<T>T;

const const bar: (val: any) => anybar = (val: anyval: any) => val: anyval;

And generics with type constraints.

// Generics With Constraints

type type Baz<T extends number> = TBaz<function (type parameter) T in type Baz<T extends number>T extends number> = function (type parameter) T in type Baz<T extends number>T;

const const baz: (val: number) => numberbaz = (val: numberval: number) => val: numberval;

The word “type” is somewhat nebulous and magical-seeming, eliciting a sense that TS is doing something incomprehensible behind the scenes. Instead, viewing types as a function which returns something aids in developing a mental model for what the TS compiler “sees” in your type code. This mental frame also helps us consider how we might convert mutable, imperative JS code into it’s functional TS type representation, to keep the two aligned.

Permutation

Aside from TS being a superset of JS, the type system also possesses a few programming features lacking in JS. For example, TS provides a basic primitive for permuting over combinations of types, via template literal strings.

In both TS and JS we could manually permute all values of some values/types, but with TS we can do this trivially.

type type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;

// 0000 - 9999
type type PinCode = "0000" | "0001" | "0002" | "0003" | "0004" | "0005" | "0006" | "0007" | "0008" | "0009" | "0010" | "0011" | "0012" | "0013" | "0014" | "0015" | "0016" | "0017" | "0018" | "0019" | "0020" | ... 9978 more ... | "9999"PinCode = `${type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9Digit}${type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9Digit}${type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9Digit}${type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9Digit}`;

A less contrived use-case might be string prefixing.

type type ApiRoute<T extends string> = `/api${T}`ApiRoute<function (type parameter) T in type ApiRoute<T extends string>T extends string> = `/api${function (type parameter) T in type ApiRoute<T extends string>T}`;

type type UserRoute = "/api/user"UserRoute = type ApiRoute<T extends string> = `/api${T}`ApiRoute<"/user">;
// -> /api/user

Comparison and Branching

While TS lacks an == operator, it can still compare two types. Bearing in mind that each type is a set, we can determine whether a type is a superset of another through the extends keyword.

type type IsNumber<T> = T extends number ? true : falseIsNumber<function (type parameter) T in type IsNumber<T>T> = function (type parameter) T in type IsNumber<T>T extends number
  ? true // [A]
  : false; // [B]

The ternary operator is used to create logical branching in TS, just like JS.

The intentionally verbose JS equivalent might look like:

const const isNumber: (value: unknown) => booleanisNumber = (value: unknownvalue: unknown) => {
  if (typeof value: unknownvalue === "number") {
    return true; // [A]
  }

  return false; // [B]
};

// or, more succinctly:
const const isNumber2: (value: unknown) => value is numberisNumber2 = (value: unknownvalue: unknown) => typeof value: unknownvalue === "number";

This idea also extends to more complicated examples, by perfectly mirroring the internal JS logic into it’s TS counterpart.

type 
type Pet = {
    type: "dog" | "cat";
    color: "blue" | "red" | "yellow";
}
Pet
= {
type: "dog" | "cat"type: "dog" | "cat"; color: "blue" | "red" | "yellow"color: "blue" | "red" | "yellow"; }; type type IsYellowDog<T extends Pet> = T["type"] extends "dog" ? T["color"] extends "yellow" ? true : false : falseIsYellowDog<function (type parameter) T in type IsYellowDog<T extends Pet>T extends
type Pet = {
    type: "dog" | "cat";
    color: "blue" | "red" | "yellow";
}
Pet
> = function (type parameter) T in type IsYellowDog<T extends Pet>T["type"] extends "dog"
? function (type parameter) T in type IsYellowDog<T extends Pet>T["color"] extends "yellow" ? true // [A] : false // [B] : false; // [C] const const isYellowDog: <T extends Pet>(pet: T) => IsYellowDog<T>isYellowDog = <function (type parameter) T in <T extends Pet>(pet: T): IsYellowDog<T>T extends
type Pet = {
    type: "dog" | "cat";
    color: "blue" | "red" | "yellow";
}
Pet
>(pet: T extends Petpet: function (type parameter) T in <T extends Pet>(pet: T): IsYellowDog<T>T) => {
if (pet: T extends Petpet.type: "dog" | "cat"type === "dog") { if (pet: T extends Petpet.color: "blue" | "red" | "yellow"color === "yellow") { return true as type IsYellowDog<T extends Pet> = T["type"] extends "dog" ? T["color"] extends "yellow" ? true : false : falseIsYellowDog<function (type parameter) T in <T extends Pet>(pet: T): IsYellowDog<T>T>; // [A] } return false as type IsYellowDog<T extends Pet> = T["type"] extends "dog" ? T["color"] extends "yellow" ? true : false : falseIsYellowDog<function (type parameter) T in <T extends Pet>(pet: T): IsYellowDog<T>T>; // [B] } return false as type IsYellowDog<T extends Pet> = T["type"] extends "dog" ? T["color"] extends "yellow" ? true : false : falseIsYellowDog<function (type parameter) T in <T extends Pet>(pet: T): IsYellowDog<T>T>; // [C}] };

If you mentally replace extends with == and squint, it’s basically obscurely written JS.

The extends keyword gets us pretty far, and does enable almost any equality comparison (with a bit of cajoling). However, other comparisons like > don’t really make sense with sets.

Math

Though TS doesn’t directly expose many operators, we still possess the primitives to implement most of them ourselves. For example, we can perform math.

type type Digit = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]Digit = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type type TimesTable = [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [0, 2, 4, 6, 8, 10, 12, 14, 16, 18], [0, 3, 6, 9, 12, 15, 18, 21, 24, 27], [0, 4, 8, 12, 16, 20, 24, 28, 32, 36], ... 4 more ..., [...]]TimesTable = [
  [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
  [0, 2, 4, 6, 8, 10, 12, 14, 16, 18],
  [0, 3, 6, 9, 12, 15, 18, 21, 24, 27],
  [0, 4, 8, 12, 16, 20, 24, 28, 32, 36],
  [0, 5, 10, 15, 20, 25, 30, 35, 40, 45],
  [0, 6, 12, 18, 24, 30, 36, 42, 48, 54],
  [0, 7, 14, 21, 28, 35, 42, 49, 56, 63],
  [0, 8, 16, 24, 32, 40, 48, 56, 64, 70],
  [0, 9, 18, 27, 36, 45, 54, 63, 72, 81]
];

type type Multiply<A extends number, B extends number> = TimesTable[A][B]Multiply<function (type parameter) A in type Multiply<A extends number, B extends number>A extends number, function (type parameter) B in type Multiply<A extends number, B extends number>B extends number> = type TimesTable = [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [0, 2, 4, 6, 8, 10, 12, 14, 16, 18], [0, 3, 6, 9, 12, 15, 18, 21, 24, 27], [0, 4, 8, 12, 16, 20, 24, 28, 32, 36], ... 4 more ..., [...]]TimesTable[function (type parameter) A in type Multiply<A extends number, B extends number>A][function (type parameter) B in type Multiply<A extends number, B extends number>B];

type 
type Ans = 12
Ans
= type Multiply<A extends number, B extends number> = TimesTable[A][B]Multiply<4, 3>;

Now, obviously this is fake math, and has limited utility. However, in this constrained case it does produce the correct output, even if the computation is only simulated, which can be useful in a few limited cases.

Real Math

And, yes, we can also perform real math by, first and foremost, inventing counting. While TS lacks a + operator, it has two sufficient ingredients for counting: arrays we can add/remove values from and recursion. At a high level, we implement counting by recursively adding to arrays and then accessing the arrays length via Array["length"].

type 
type ExampleLength = 3
ExampleLength
= [1, 2, 3]["length"];

To add two arrays together, we can do:

type type ConcatLength<A extends any[], B extends any[]> = [...A, ...B]["length"]ConcatLength<function (type parameter) A in type ConcatLength<A extends any[], B extends any[]>A extends any[], function (type parameter) B in type ConcatLength<A extends any[], B extends any[]>B extends any[]> = [...function (type parameter) A in type ConcatLength<A extends any[], B extends any[]>A, ...function (type parameter) B in type ConcatLength<A extends any[], B extends any[]>B]["length"];

type 
type Length = 5
Length
= type ConcatLength<A extends any[], B extends any[]> = [...A, ...B]["length"]ConcatLength<[1, 2, 3], [4, 5]>;

The spread operator (...) is a particularly helpful operator also found in JS, which provides a functional way to append arrays, at the type level.

From here, we just need to populate arrays based on scalar numbers.

type type CreateArray<T extends number, Acc extends any[] = []> = Acc["length"] extends T ? Acc : CreateArray<T, [...Acc, 0]>CreateArray<
  function (type parameter) T in type CreateArray<T extends number, Acc extends any[] = []>T extends number,
  function (type parameter) Acc in type CreateArray<T extends number, Acc extends any[] = []>Acc extends any[] = [] // An "Accumulator" array which we recursively fill up
> =
  // The recursion base case check.
  function (type parameter) Acc in type CreateArray<T extends number, Acc extends any[] = []>Acc["length"] extends function (type parameter) T in type CreateArray<T extends number, Acc extends any[] = []>T
    ? // When Array.length == T, we can return it
      function (type parameter) Acc in type CreateArray<T extends number, Acc extends any[] = []>Acc
    : // Create a new array with one more element in it
      type CreateArray<T extends number, Acc extends any[] = []> = Acc["length"] extends T ? Acc : CreateArray<T, [...Acc, 0]>CreateArray<function (type parameter) T in type CreateArray<T extends number, Acc extends any[] = []>T, [...function (type parameter) Acc in type CreateArray<T extends number, Acc extends any[] = []>Acc, 0]>; // The '0' is arbitrary here.

type 
type Arr = [0, 0, 0, 0, 0]
Arr
= type CreateArray<T extends number, Acc extends any[] = []> = Acc["length"] extends T ? Acc : CreateArray<T, [...Acc, 0]>CreateArray<5>;

And now we can add two numbers, for reals.

type type Add<A extends number, B extends number> = [...CreateArray<A, []>, ...CreateArray<B, []>]["length"]Add<function (type parameter) A in type Add<A extends number, B extends number>A extends number, function (type parameter) B in type Add<A extends number, B extends number>B extends number> = type ConcatLength<A extends any[], B extends any[]> = [...A, ...B]["length"]ConcatLength<
  type CreateArray<T extends number, Acc extends any[] = []> = Acc["length"] extends T ? Acc : CreateArray<T, [...Acc, 0]>CreateArray<function (type parameter) A in type Add<A extends number, B extends number>A>,
  type CreateArray<T extends number, Acc extends any[] = []> = Acc["length"] extends T ? Acc : CreateArray<T, [...Acc, 0]>CreateArray<function (type parameter) B in type Add<A extends number, B extends number>B>
>;

type 
type Result = 13
Result
= type Add<A extends number, B extends number> = [...CreateArray<A, []>, ...CreateArray<B, []>]["length"]Add<5, 8>;

Our Add type initialises two arrays of length A and B, respectively. These are then concatenated and the length is read, resulting in the summed value. Clearly this is a fairly roundabout way of implementing a basic sum operation, and is rarely used in real-world applications. But it still demonstrates how TS primitives can be composed into more sophisticated functionality.

Iteration

Like our strange approach to addition above, we can also perform iteration in some familiar and some unfamiliar ways.

The most relevant form of iteration for regular TS code is through so-called mapped types. In essence, mapped types enabled iterating through the entries of an object, and allow modifying both the value and the key.

We might reverse they key-value pairs of an object like so:

// For each entry: [key, val] -> [val, key]
type type Reverse<T extends Record<string, string>> = { [$Key in keyof T as T[$Key]]: $Key; }Reverse<function (type parameter) T in type Reverse<T extends Record<string, string>>T extends type Record<K extends keyof any, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, string>> = {
// |---------| Formatting the output object's key [function (type parameter) $Key$Key in keyof function (type parameter) T in type Reverse<T extends Record<string, string>>T as function (type parameter) T in type Reverse<T extends Record<string, string>>T[function (type parameter) $Key$Key]]: function (type parameter) $Key$Key; }; type
type Result = {
    bar: "foo";
}
Result
= type Reverse<T extends Record<string, string>> = { [$Key in keyof T as T[$Key]]: $Key; }Reverse<{ foo: "bar"foo: "bar" }>;
// // // //

To extend this idea, we might want to join the key and value, and then extract the union of all possible computed values.

type 
type Names = {
    Smith: ["Adam", "John"];
    Jackson: ["Andrew", "Michael"];
}
Names
= {
type Smith: ["Adam", "John"]Smith: ["Adam", "John"]; type Jackson: ["Andrew", "Michael"]Jackson: ["Andrew", "Michael"]; }; // For each entry: `${(element of) value} ${key}` type type FullName<T extends Record<string, string[]>> = { [$Key in keyof T]: `${T[$Key][number]} ${$Key & string}`; }[keyof T]FullName<function (type parameter) T in type FullName<T extends Record<string, string[]>>T extends type Record<K extends keyof any, T> = { [P in K]: T; }
Construct a type with a set of properties K of type T
Record
<string, string[]>> = {
[function (type parameter) $Key$Key in keyof function (type parameter) T in type FullName<T extends Record<string, string[]>>T]: `${function (type parameter) T in type FullName<T extends Record<string, string[]>>T[function (type parameter) $Key$Key][number]} ${function (type parameter) $Key$Key & string}`; }[keyof function (type parameter) T in type FullName<T extends Record<string, string[]>>T]; type
type Result = "Adam Smith" | "John Smith" | "Andrew Jackson" | "Michael Jackson"
Result
= type FullName<T extends Record<string, string[]>> = { [$Key in keyof T]: `${T[$Key][number]} ${$Key & string}`; }[keyof T]FullName<
type Names = {
    Smith: ["Adam", "John"];
    Jackson: ["Andrew", "Michael"];
}
Names
>;

While mapped types work with objects, they don’t work with arrays since arrays keys are not of type "0" | "1" | ..., but rather a less-specific number type, which fails to index the correct value within the array. Instead we can use recursion to iteratively pick items out of arrays. This is also where FP concepts like “heads” and “tails” are helpful.

type type Names = [["Smith", ["Adam", "John"]], ["Jackson", ["Andrew", "Michael"]]]Names = [
  ["Smith", ["Adam", "John"]], // [key, value]
  ["Jackson", ["Andrew", "Michael"]]
];

type type Entry = [string, string[]]Entry = [string, string[]];

type type FullName<T extends Entry[]> = T extends [infer $Head extends Entry, ...infer $Tail extends Entry[]] ? `${$Head[1][number]} ${$Head[0]}` | FullName<$Tail> : neverFullName<function (type parameter) T in type FullName<T extends Entry[]>T extends type Entry = [string, string[]]Entry[]> = function (type parameter) T in type FullName<T extends Entry[]>T extends [
  infer function (type parameter) $Head$Head extends type Entry = [string, string[]]Entry, // Extract first (head) item
  ...infer function (type parameter) $Tail$Tail extends type Entry = [string, string[]]Entry[] // Extract the remaining items
]
  ?
      | `${function (type parameter) $Head$Head[1][number]} ${function (type parameter) $Head$Head[0]}` // Format head item
      | type FullName<T extends Entry[]> = T extends [infer $Head extends Entry, ...infer $Tail extends Entry[]] ? `${$Head[1][number]} ${$Head[0]}` | FullName<$Tail> : neverFullName<function (type parameter) $Tail$Tail> // Process the remaining items
  : never;

type 
type Result = "Adam Smith" | "John Smith" | "Andrew Jackson" | "Michael Jackson"
Result
= type FullName<T extends Entry[]> = T extends [infer $Head extends Entry, ...infer $Tail extends Entry[]] ? `${$Head[1][number]} ${$Head[0]}` | FullName<$Tail> : neverFullName<type Names = [["Smith", ["Adam", "John"]], ["Jackson", ["Andrew", "Michael"]]]Names>;

While this gets the job done, it’s clearly more complicated and code-heavy. Worse, it risks hitting the arbitrary recursion limits set by TS, set to almost 1000 recursive depth.

If we briefly hark back to our recursive CreateArray type from the above “Math” section, we can verify the depth limit like so.

type type SmallArray = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ... 944 more ..., 0]SmallArray = type CreateArray<T extends number, Acc extends any[] = []> = Acc["length"] extends T ? Acc : CreateArray<T, [...Acc, 0]>CreateArray<999>;
type type BigArray = anyBigArray = CreateArray<1000>;
Type instantiation is excessively deep and possibly infinite.

This depth limit can become a real problem for highly complex types. But even before hitting the limits, TS compilation performance will degrade with heavy use of recursion. For that reason, mapping over objects is preferable.

String Iteration

Similar to how we “pull apart” arrays one-by-one, recursively, we can also achieve the same idea with strings. Once again template string literals come to the rescue. With the help of the infer keyword, we can infer what parts of a string are.

type type SplitByDot<T extends string> = T extends `${infer $Head}.${infer $Rest}` ? [$Head, ...SplitByDot<$Rest>] : T extends `.${infer $Rest}` ? [$Rest] : [T]SplitByDot<function (type parameter) T in type SplitByDot<T extends string>T extends string> = function (type parameter) T in type SplitByDot<T extends string>T extends `${infer function (type parameter) $Head$Head}.${infer function (type parameter) $Rest$Rest}`
  ? [function (type parameter) $Head$Head, ...type SplitByDot<T extends string> = T extends `${infer $Head}.${infer $Rest}` ? [$Head, ...SplitByDot<$Rest>] : T extends `.${infer $Rest}` ? [$Rest] : [T]SplitByDot<function (type parameter) $Rest$Rest>]
  : function (type parameter) T in type SplitByDot<T extends string>T extends `.${infer function (type parameter) $Rest$Rest}`
  ? [function (type parameter) $Rest$Rest]
  : [function (type parameter) T in type SplitByDot<T extends string>T];

type 
type Result = ["a", "b", "c"]
Result
= type SplitByDot<T extends string> = T extends `${infer $Head}.${infer $Rest}` ? [$Head, ...SplitByDot<$Rest>] : T extends `.${infer $Rest}` ? [$Rest] : [T]SplitByDot<"a.b.c">;

Inference occurs left to right, where the latter inferences or types “consume” the remainder of the string. So for example, ${infer A}${str} would result in A being a single character. In the above example, we infer up to the . separator.

We can also perform type casting/conversion through this inference.

type type AsNumber<T extends string> = T extends `${infer R extends number}` ? R : neverAsNumber<function (type parameter) T in type AsNumber<T extends string>T extends string> = function (type parameter) T in type AsNumber<T extends string>T extends `${infer function (type parameter) RR extends number}`
  ? function (type parameter) RR
  : never;

type 
type Result = 5
Result
= type AsNumber<T extends string> = T extends `${infer R extends number}` ? R : neverAsNumber<"5">;

Standard Library

TS even offers it’s very own standard library, separate from the JS stdlib. Namely, utility types provide common functionality, like Partial and NonNullable which would otherwise be reimplemented in most codebases. Other utility types like Lowercase, Uncapitalize and NoInfer are “intrinsic”, meaning they have no type definition as such. Instead, their meaning is intrinsic to the compiler, and therefore implemented outside of TS-world.

Intrinsic types are either a symbolic marker for the compiler or require some actual data, like upper/lower case letter transforms, which are better left to evaluation within the compiler, instead of specifying every conversion explicitly in TS code.

For example, the source code for Capitalize is:

/**
 * Convert first character of string literal type to uppercase
 */
type type Capitalize<S extends string> = intrinsic
Convert first character of string literal type to uppercase
Capitalize
<function (type parameter) S in type Capitalize<S extends string>S extends string> = intrinsic;

Subroutines and Abstraction

As shown in the “Types as Functions” section, it’s helpful to think about types as functions. Doubling down on that idea, we can begin to think better about how to decompose what might otherwise be intractably complex types into smaller types which solve the smaller sub-problems at hand. In other words, it’s helpful to decompose large, complex code, whether runtime or type code.

Type code at the application-level tends to be a flat-ish collection of relatively isolated types. Like any real programming language, TS can benefit from decomposing problems into smaller problems and abstracting large types into smaller sub-types. Since TS lacks a console.log equivalent, decomposing complex is critical for debugging more complex types.

We’ve already observed some of these techniques, for example the CreateArray in the “Maths” section is a good example of a “sub-routine” we want to abstract away.

Practical Tips

While the TS docs cover (some of) the above, here I’ll cover less formal tips acquired over the years.

Naming Types

Naming types, like naming variables, is not always straightforward. As you create more complex types, you’ll start to create increasingly more utility types which, instead of describing the shape of some thing, are instead supposed to solely manipulate another type. For example, while we might have a User type which describes a shape, we might have a RemoveOptional<T> type which – as the name suggests – removes all optional fields. Here, we see that generic functions can be both a container type, or a procedure of sorts.

Speaking of generic types, we need not solely use terse generic parameter names like T and U. Like the TS docs, I too have opted to use that convention in this post, but in practice, longer parameter names are often necessary when dealing with several parameters. Still, I tend to prefix these names with T to disambiguate between parameters and other type definitions.

There are also another kind of type which appears within a given type. These are types that are inferred, either through iteration or infering. I tend to $-prefix these ephemeral variables to distinguish between the rest. We’ve seen this convention used in the form of object [$Key in keyof T]: ... and T extends [infer $Head, ...infer $Tail].

(“Type variables” is just what I call them - I’m not sure what the official term is).

Debugging and Decomposition

The primary way we “log” in the world of types is by hovering over evaluated types. TS is side-effect free, with the exception of perhaps slowing down your IDE a little bit. This means we can only inspect return types, not intermediary types. To peek into intermediary types, we can break our types apart and inspect the output of our smaller types.

Branching tends to be another source of complexity and error. Complex types typically use recursion and branching liberally. As such, we can peek into the outputs of various branches by a) terminating the recursion and b) distinguishing various branches.

For example, if we can’t figure out why some complex type resolves to never or any, we can relieve our head of banging against the wall by ascertaining which branches were traversed contrary to our expectations. Take the below type intended to “unbox” types recursively.

type type Unbox<T> = T extends string | number | boolean | symbol ? T : T extends Promise<infer R> ? Unbox<R> : T extends (infer R)[] ? Unbox<R> : T extends () => infer R ? Unbox<...> : TUnbox<function (type parameter) T in type Unbox<T>T> = function (type parameter) T in type Unbox<T>T extends number | string | boolean | symbol
  ? function (type parameter) T in type Unbox<T>T
  : function (type parameter) T in type Unbox<T>T extends interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<infer function (type parameter) RR>
? type Unbox<T> = T extends string | number | boolean | symbol ? T : T extends Promise<infer R> ? Unbox<R> : T extends (infer R)[] ? Unbox<R> : T extends () => infer R ? Unbox<...> : TUnbox<function (type parameter) RR> : function (type parameter) T in type Unbox<T>T extends interface Array<T>Array<infer function (type parameter) RR> ? type Unbox<T> = T extends string | number | boolean | symbol ? T : T extends Promise<infer R> ? Unbox<R> : T extends (infer R)[] ? Unbox<R> : T extends () => infer R ? Unbox<...> : TUnbox<function (type parameter) RR> : function (type parameter) T in type Unbox<T>T extends () => infer function (type parameter) RR ? type Unbox<T> = T extends string | number | boolean | symbol ? T : T extends Promise<infer R> ? Unbox<R> : T extends (infer R)[] ? Unbox<R> : T extends () => infer R ? Unbox<...> : TUnbox<function (type parameter) RR> : function (type parameter) T in type Unbox<T>T;

Like regular code, it’s not uncommon to get erroneous behaviour prior to a bit of tinkering and adjusting. As I mentioned, never and any tend to appear when union-ing or intersecting types that are incompatible or too broad. It’s especially helpful in these cases to debug each branch to see where our generic parameter T is “travelling”.

type type Unbox<T> = T extends string | number | boolean | symbol ? 1 : T extends Promise<infer R> ? 2 : T extends (infer R)[] ? 3 : T extends () => infer R ? 4 : 5Unbox<function (type parameter) T in type Unbox<T>T> = function (type parameter) T in type Unbox<T>T extends number | string | boolean | symbol
  ? 1
  : function (type parameter) T in type Unbox<T>T extends interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<infer function (type parameter) RR>
? 2 // --> First iteration hits here : function (type parameter) T in type Unbox<T>T extends interface Array<T>Array<infer function (type parameter) RR> ? 3 : function (type parameter) T in type Unbox<T>T extends () => infer function (type parameter) RR ? 4 : 5; type
type Result = 2
Result
= type Unbox<T> = T extends string | number | boolean | symbol ? 1 : T extends Promise<infer R> ? 2 : T extends (infer R)[] ? 3 : T extends () => infer R ? 4 : 5Unbox<interface Promise<T>
Represents the completion of an asynchronous operation
Promise
<() => string[]>>;

For example, we can litter each path with a different number, which clearly shows which branch our logic evaluated to, and therefore where any unexpected problems might arise.

Similarly, with or without branching, we can arbitrarily “print” out whatever values by placing everything we want in an array, string or object.

// Taken the "String Iteration" section
type type SplitByDot<T extends string> = T extends `${infer $Head}.${infer $Rest}` ? [`DEBUG: [${$Head}] [${$Rest}] [${T}]`] : T extends `.${infer $Rest}` ? [$Rest] : [T]SplitByDot<function (type parameter) T in type SplitByDot<T extends string>T extends string> = function (type parameter) T in type SplitByDot<T extends string>T extends `${infer function (type parameter) $Head$Head}.${infer function (type parameter) $Rest$Rest}`
  ? [`DEBUG: [${function (type parameter) $Head$Head}] [${function (type parameter) $Rest$Rest}] [${function (type parameter) T in type SplitByDot<T extends string>T}]`] // <- Inspect every variable at this point
  : function (type parameter) T in type SplitByDot<T extends string>T extends `.${infer function (type parameter) $Rest$Rest}`
  ? [function (type parameter) $Rest$Rest]
  : [function (type parameter) T in type SplitByDot<T extends string>T];

type 
type Result = ["DEBUG: [a] [b.c] [a.b.c]"]
Result
= type SplitByDot<T extends string> = T extends `${infer $Head}.${infer $Rest}` ? [`DEBUG: [${$Head}] [${$Rest}] [${T}]`] : T extends `.${infer $Rest}` ? [$Rest] : [T]SplitByDot<"a.b.c">;

Testing

On top of manually verifying that a type works as expected, we can also automate this process using type-assertion libraries. Standalone libraries like ts-expect and expect-type can help, and some testing frameworks, like vitest automatically ship with the latter.

import { const describe: SuiteAPI
Creates a suite of tests, allowing for grouping and hierarchical organization of tests. Suites can contain both tests and other suites, enabling complex test structures.
@paramname - The name of the suite, used for identification and reporting.@paramfn - A function that defines the tests and suites within this suite.@example```ts // Define a suite with two tests describe('Math operations', () => { test('should add two numbers', () => { expect(add(1, 2)).toBe(3); }); test('should subtract two numbers', () => { expect(subtract(5, 2)).toBe(3); }); }); ```@example```ts // Define nested suites describe('String operations', () => { describe('Trimming', () => { test('should trim whitespace from start and end', () => { expect(' hello '.trim()).toBe('hello'); }); }); describe('Concatenation', () => { test('should concatenate two strings', () => { expect('hello' + ' ' + 'world').toBe('hello world'); }); }); }); ```
describe
, const test: TestAPI
Defines a test case with a given name and test function. The test function can optionally be configured with test options.
@paramname - The name of the test or a function that will be used as a test name.@paramoptionsOrFn - Optional. The test options or the test function if no explicit name is provided.@paramoptionsOrTest - Optional. The test function or options, depending on the previous parameters.@throws{Error} If called inside another test function.@example```ts // Define a simple test test('should add two numbers', () => { expect(add(1, 2)).toBe(3); }); ```@example```ts // Define a test with options test('should subtract two numbers', { retry: 3 }, () => { expect(subtract(5, 2)).toBe(3); }); ```
test
, const expectTypeOf: _ExpectTypeOf
Similar to Jest's `expect`, but with type-awareness. Gives you access to a number of type-matchers that let you make assertions about the form of a reference or generic type parameter.
@exampleimport {foo, bar} from '../foo' import {expectTypeOf} from 'expect-type' test('foo types', () => { // make sure `foo` has type {a: number} expectTypeOf(foo).toMatchTypeOf({a: 1}) expectTypeOf(foo).toHaveProperty('a').toBeNumber() // make sure `bar` is a function taking a string: expectTypeOf(bar).parameter(0).toBeString() expectTypeOf(bar).returns.not.toBeAny() })@descriptionSee the [full docs](https://npmjs.com/package/expect-type#documentation) for lots more examples.
expectTypeOf
} from "vitest";
describe<object>(name: string | Function, fn?: SuiteFactory<object> | undefined, options?: number | TestOptions): SuiteCollector<object> (+2 overloads)
Creates a suite of tests, allowing for grouping and hierarchical organization of tests. Suites can contain both tests and other suites, enabling complex test structures.
@paramname - The name of the suite, used for identification and reporting.@paramfn - A function that defines the tests and suites within this suite.@example```ts // Define a suite with two tests describe('Math operations', () => { test('should add two numbers', () => { expect(add(1, 2)).toBe(3); }); test('should subtract two numbers', () => { expect(subtract(5, 2)).toBe(3); }); }); ```@example```ts // Define nested suites describe('String operations', () => { describe('Trimming', () => { test('should trim whitespace from start and end', () => { expect(' hello '.trim()).toBe('hello'); }); }); describe('Concatenation', () => { test('should concatenate two strings', () => { expect('hello' + ' ' + 'world').toBe('hello world'); }); }); }); ```
describe
("...", () => {
test<object>(name: string | Function, fn?: TestFunction<object> | undefined, options?: number | TestOptions): void (+2 overloads)
Defines a test case with a given name and test function. The test function can optionally be configured with test options.
@paramname - The name of the test or a function that will be used as a test name.@paramoptionsOrFn - Optional. The test options or the test function if no explicit name is provided.@paramoptionsOrTest - Optional. The test function or options, depending on the previous parameters.@throws{Error} If called inside another test function.@example```ts // Define a simple test test('should add two numbers', () => { expect(add(1, 2)).toBe(3); }); ```@example```ts // Define a test with options test('should subtract two numbers', { retry: 3 }, () => { expect(subtract(5, 2)).toBe(3); }); ```
test
("...", () => {
type type Result = ["hello", "world"]Result = type SplitByDot<T extends string> = T extends `${infer $Head}.${infer $Rest}` ? [$Head, ...SplitByDot<$Rest>] : T extends `.${infer $Rest}` ? [$Rest] : [T]SplitByDot<"hello.world">; type type Expected = ["hello", "world"]Expected = ["hello", "world"]; expectTypeOf<["hello", "world"]>(): PositiveExpectTypeOf<["hello", "world"]> (+1 overload)
Asserts the expected type of a value without providing an actual value.
@returnsAn object representing the expected type assertion.
expectTypeOf
<type Result = ["hello", "world"]Result>().PositiveExpectTypeOf<["hello", "world"]>.toMatchTypeOf: <Expected>() => true (+1 overload)
A less strict version of {@linkcode toEqualTypeOf `.toEqualTypeOf()` } that allows for extra properties. This is roughly equivalent to an `extends` constraint in a function type argument.
@example<caption>Using generic type argument syntax</caption> ```ts expectTypeOf({ a: 1, b: 1 }).toMatchTypeOf<{ a: number }>() ```@example<caption>Using inferred type syntax by passing a value</caption> ```ts expectTypeOf({ a: 1, b: 1 }).toMatchTypeOf({ a: 2 }) ```@paramMISMATCH - The mismatch arguments.@returns`true`.
toMatchTypeOf
<type Expected = ["hello", "world"]Expected>();
expectTypeOf<["hello", "world"]>(): PositiveExpectTypeOf<["hello", "world"]> (+1 overload)
Asserts the expected type of a value without providing an actual value.
@returnsAn object representing the expected type assertion.
expectTypeOf
<type Result = ["hello", "world"]Result>().PositiveExpectTypeOf<["hello", "world"]>.toMatchTypeOf: <["hell", "world"]>(value: ["hell", "world"] & AValue, MISMATCH_0: Mismatch) => true (+1 overload)
A less strict version of {@linkcode toEqualTypeOf `.toEqualTypeOf()` } that allows for extra properties. This is roughly equivalent to an `extends` constraint in a function type argument.
@example<caption>Using generic type argument syntax</caption> ```ts expectTypeOf({ a: 1, b: 1 }).toMatchTypeOf<{ a: number }>() ```@example<caption>Using inferred type syntax by passing a value</caption> ```ts expectTypeOf({ a: 1, b: 1 }).toMatchTypeOf({ a: 2 }) ```@paramvalue - The value to compare against the expected type.@paramMISMATCH - The mismatch arguments.@returns`true`.
toMatchTypeOf
<["hell", "world"]>();
Type '["hell", "world"]' does not satisfy the constraint '{ 0: "Expected: literal string: hell, Actual: literal string: hello"; 1: "world"; }'. Types of property '0' are incompatible. Type '"hell"' is not assignable to type '"Expected: literal string: hell, Actual: literal string: hello"'.
}); });

Functional-thinking

The TS type system is not only a language within a language, but is a language with a fairly different mental model. While JS is fundamentally an imperative language, and therefore allows mutation, TS is much more like a functional language. So instead of loops and incrementing and updating variables, TS is essentially a long pipeline which evaluated to a type. TS forces us to think of how types flow through our type definitions, to result in the final evaluated type, which is then what is used by the compiler and type-hints.

Performance

Finally, more advanced TS can quickly cause issues like the Type instantiation is excessively deep and possibly infinite message familiar to any seasoned so-called “TypeScript wizard”. While recursion and looping are the common causes of poor TS type performance, using interfaces when possible also seems to improve performance, perhaps because interfaces don’t support as many features as types do, and therefore require less figuring out by the compiler.

Summary

In summary, TypeScript is at the very least pretty cool. Much of the more advanced techniques probably shouldn’t be used too liberally in application-level codebases. However, many techniques ostensibly deemed too esoteric by some are actually useful in practice.

TS is the only language (I know of) that has no runtime footprint whatsoever (except enums). And yet, we’ve found it useful enough as an industry to widely adopt. I believe there are some features that can be usefully sprinkled throughout “regular” codebases without immediately rendering your codebase “over-engineered”. Even aside from expanding one’s repertoire of TS techniques, understanding how and why TS works as it does can make existing, simple TS types less elusive.

In short, TypeScript is cool, actually.