Notes on

TypeScript Cookbook

by Stefan Baumgartner

| 26 min read


Most of the code I have written while reading this book is included in this note, but I have published the repository: typescript-cookbook.

1. Project Setup

1.1 Type-Checking JavaScript

I didn’t know that it was possible to create type definitions with JSDoc:

/**
 * @typedef {Object} Article
 * @property {number} price
 * @property {string} name
 * @property {boolean=} sold
 */

/**
 *
 * @param {[Article]} articles
 * @returns {number}
 */
function totalAmount(articles) {
    return articles.reduce((acc, article) => acc + article.price, 0);
}

The = in boolean= makes it optional.

1.2 Installing TypeScript

tsconfig.json contains configuration for TypeScript.

target defines which syntax the TypeScript should be compiled to (e.g. es2016 for ECMAScript 2016 compatible syntax).

If module is commonjs, you can write ECMAScript module syntax, but TypeScript will convert it to CommonJS:

// From ECMAScript module syntax:
import { name } from "./module";

console.log(name);

// To CommonJS:
const module_1 = require("./module");
console.log(module_1.name);

CJS was the module system for Node.js, but Node has since adopted ECMAScript modules as well.

strict mode means TS will behave differently in some areas. If TS introduces a breaking change because the view on the type system changes, it’ll be incorporated in strict mode. This means your code can break if you update TS and always run in strict mode. But you can turn certain strict mode features on or off on a per-feature basis.

You should set rootDir and outDir. rootDir tells TS to pick up source files from the specified folder and put the compiled files in outDir.

1.6 Setting Up a Full-Stack Project

If you’re writing a full-stack application targeting Node.js and the browser with shared dependencies, you’ll need to create two tsconfig files for each frontend and backend, and load the shared dependencies as composites.

While both Node.js and the browser run JS, they are different environments. The differences are so big it’s hard to create a single TS project that handles both.

Say you’re writing an Express.js server in Node.js:

$ npm install --save express
$ npm install -D @types/expresss @types/node

Then create a folder server and manually create server/tsconfig.json (not via tsc):

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["ESNext"],
    "module": "commonjs",
    "rootDir": "./",
    "moduleResolution": "node",
    "types": ["node"],
    "outDir": "../dist/server",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

module is commonjs (but your import and exports will be transpiled to their CJS counterpart). You can use npx tsc -p server/tsconfig.json to compile the sever-side code.

And for the client, create client/tsconfig.json:

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["DOM", "ESNext"],
    "module": "ESNext",
    "rootDir": "./",
    "moduleResolution": "node",
    "types": [],
    "outDir": "../dist/client",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

Notably, we’ve added DOM to lib to get type definitions for everything related to the browser. types is empty to remove node from the global typings. You can only install type definitions per package.json, but we don’t want the node type definitions in the client part. Compile with npx tsc -p client/tsconfig.json.

It’s probably better to set it up this way with separate folders each containing their own tsconfig.json files. While you could have used tsconfig.server.json and tsconfig.client.json, your editor may just ignore these and silently use the default configurations.

To get shared dependencies, you could use project references and composite projects. You extract shared code into its own folder but tell TS it’s meant to be a dependency project of another one. Create a shared folder at the same level as client and server and a tsconfig.json file in that folder:

{
    "compilerOptions": {
      "composite": true,
      "target": "ESNext",
      "module": "ESNext",
      "rootDir": "../shared/",
      "moduleResolution": "Node",
      "types": [],
      "declaration": true,
      "outDir": "../dist/shared",
      "esModuleInterop": true,
      "forceConsistentCasingInFileNames": true,
      "strict": true,
      "skipLibCheck": true
    },
  }

Notably, composite is set to true so that other projects can reference this one, and declaration is set to true so that it generates .d.ts files from your code, allowing other projects to consume the type information.

Now include this in your client and server tsconfigs:

{
  "compilerOptions": {
    // Same as before
  },
  "references": [
    { "path": "../shared/tsconfig.json" }
  ]
}

Just note that it can’t combine two different module systems (CJS in Node, ES modules in browser) in one compiled file. With an ESNext module you can’t import it in CJS code, and vice versa.

So either compile to CJS and let a bundler take care of module resolution for the browser, or compile to ES modules and write modern Node.js applications based on ES modules (recommended!).

1.9 Loading Different Module Types in Node

To use ES modules in Node.js & the CJS interoperability feature for libraries, set TS’s module resolution to NodeNext and name your files .mts or .cts.

.mts can import .js, .cjs, and .mjs (or ts in each case), so it seems to be the most flexible. Can’t import ES modules from CJS.

1.11 Using Predefined Configurations

tsconfig/bases shares predefined configurations you can install and use. That way, you don’t have to spend a ton of time setting up your projects in the future if you don’t know where to start.

2. Basic Types

2.1 Annotating Effectively

You don’t have to annotate everything with types. TS is great at inferring!

TypeScript has a structural type system. This means the compiler only takes into account the members (properties) of a type, not the actual name.

That also means that both of these work:

type Person = {
	name: string;
	age: number;
};

function getPersonTyped(): Person {
	return {
		name: "John",
		age: 30,
	};
}

function getPersonNotTyped() {
	return {
		name: "John",
		age: 30,
	};
}

function printPerson(person: Person) {
	console.log(person.name, person.age);
}

// Both OK!
printPerson(getPersonTyped());
printPerson(getPersonNotTyped());

2.2 Working with any and unknown

Use any if you effectively want to deactivate typing; use unknown when you need to be cautious.

Both any and unknown are top types, meaning every value is compatible with any or unknown.

Many think you shouldn’t use any in your codebase, but there are situations where it can be useful:

  • When migrating from JS to TS
  • When dealing with untyped third-party dependencies
  • When just quickly prototyping something in JavaScript

It’s a good idea to activate noImplicitAny in tsconfig.json (activated by default in strict mode).

2.4 Working with Tuple Types

// Tuple type:
const person: [string, number] = ["Christian", 25];

// With labels:
type Person = [name: string, age: number];

The labels don’t compile to anything. They just allow you to be clearer about what you expect from each element.

You could even use tuple types to annotate function arguments:

function hello(...args: [name: string, age: number]) {
	console.log(`Hello ${args[0]}, you are ${args[1]} years old`);
}

2.5 Understanding Interfaces Versus Type Aliases

You can declare object types with either interfaces or type aliases. Use type aliases for types within your project’s boundary, and use interfaces for contracts that are meant to be consumed by others.

There used to be many differences between the methods. Now there aren’t many. You may find a lot of outdated discussion on these differences.

Interfaces allow for declaration merging, while type aliases don’t. Declaration merging allows you to add properties to an interface even after it has been declared:

interface Person {
  name: string;
}

interface Person {
  age: number;
}

// Person is now { name: string; age: number; }

Declaration merging is great if you’re writing a library consumed by other parts in your project (or other projects by other teams). It’ll let you define an interface that describes your application but allows your users to adapt it, like a plug-in system.

But within your own module’s boundaries, using type aliases prevents you from accidentally reusing or extending already declared types.

Interfaces have been considered much more performant in their evaluation than type aliases. But take that with a grain of salt.

2.6 Defining Function Overloads

type CallbackFn = () => void;

// Types
function task(name: string, dependencies: string[]): void;
function task(name: string, callback: CallbackFn): void;
function task(name: string, dependencies: string[], callback: CallbackFn): void;

// Implementation
function task(
	name: string,
	param2?: string[] | CallbackFn,
	param3?: CallbackFn,
) {
	let dependencies: string[] = [];
	let callback: CallbackFn | undefined;

	if (Array.isArray(param2)) {
		dependencies = param2;
	} else if (typeof param2 === "function") {
		callback = param2;
	}

	if (typeof param3 === "function") {
		callback = param3;
	}

	console.log(
		`Running task ${name} with dependencies ${dependencies.join(", ")}`,
	);
	callback?.();
}

task("default", ["scripts", "styles"]);
task("scripts", ["lint"], () => {
	console.log("Done running scripts.");
});
task("styles", () => {
	console.log("Running styles.");
});

2.7 Defining this Parameter Types

When you’re writing a callback function that makes assumptions about this but don’t know how to define this when writing the function standalone, define a this parameter type at the beginning of the function signature.

const button = document.querySelector("button");
button?.addEventListener("click", handleToggle);

function handleToggle(this: HTMLButtonElement) {
    this.classList.toggle("active");
}

If you didn’t define this, it would say that this implicitly has type any when trying to use it above. The argument gets removed once compiled.

There are also some nice helpers to be aware of here:

type TogglFn = typeof handleToggle;
// (this: HTMLButtonElement) => void

type WithoutThis = OmitThisParameter<TogglFn>;
// () => void

type TogglFnThis = ThisParameterType<TogglFn>;
// HTMLButtonElement

3. The Type System

3.1 Modeling Data with Union and Intersection Types

A type can be thought of as a set of compatible values. For each of the values, TS checks if it’s compatible with a certain type. For objects, this includes values with more properties than defined in their type.

A union type is a union of sets.

A intersection type is also like its counterpart from set theory: compatible values should be of type A and B.

Unions work great with literal types, e.g.:

type Status = "done" | "in progress" | "todo";

3.3 Exhaustiveness Checking with the Assert never Technique

Say you have a bunch of discriminated union types that change over time as you add new parts to the union. It can be difficult to track all the occurrences in your code where you need to adapt to these changes.

To address this, create an exhaustiveness check where you assert that all remaining cases can never happen.

This way, if you add another type to the union below, you’ll be notified by the type checker.

I knew of a way to do it, but the book presented another one:

type Circle = {
    radius: number;
    kind: "circle";
}

type Square = {
    x: number;
    kind: "square";
}

type Triangle = {
    x: number;
    y: number;
    kind: "triangle";
}

type Rectangle = {
    x: number;
    y: number;
    kind: "rectangle";
}

// Errors if you include Rectangle, as it isn't handled in the switch statement
type Shape = Circle | Square | Triangle;
// type Shape = Circle | Square | Triangle | Rectangle;

function assertNever(x: never): never {
    throw new Error(`Unexpected object: ${x}`);
}

function area(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "square":
            return shape.x * shape.x;
        case "triangle":
            return shape.x * shape.y / 2;
        default: {
            // You can do:
            assertNever(shape);

            // Or you can do:
            // const _exhaustiveCheck: never = shape;
            // return _exhaustiveCheck;
        }
    }
}

// Need to excplicitly define it as `Circle` or you get an error.
// (or see 3.4!)
const c: Circle = {
    radius: 1,
    kind: "circle",
}

console.log(area(c));

3.4 Pinning Types with Const Context

Instead of having to explicitly define c as Circle in 3.3, you can just use const context type assertions:

const circle = {
	radius: 2,
	kind: "circle" as const,
};

const circle = {
	radius: 2,
	kind: "circle",
} as const;

The problem is that if didn’t either explicitly define c as Circle or as const, TypeScript would just think that the kind is a string, even though you defined it as circle.

When you use literals for data, TS usually infers the broader set, making the values incompatible to the types defined.

3.5 Narrowing Types with Type Predicates

type Dice = 1 | 2 | 3 | 4 | 5 | 6;

function isDice(value: number): value is Dice {
    return [1, 2, 3, 4, 5, 6].includes(value);
}

function rollDice(input: number) {
    if (isDice(input)) {
        // Input is `Dice`
    } else {
        // Input is `number`
    }
}

Now TypeScript has an idea of what the actual type is.

You can’t just do any assertion with type predicates. The type has to be narrower than the original type. Just note that it doesn’t check if the condition makes sense. You could just return true.

3.6 Understanding void

First a little aside on void in JS. Then we’ll talk void in TS.

void also exists in JavaScript. It evaluates the expression next to it but returns undefined:

const i = void 2; // i = `undefined`.

You can use this to call immediately invoked functions (and not pollute the global namespace!):

void function aRecursion(i: number) {
    if (i > 0) {
        console.log(i - 1);
        aRecursion(i - 1);
    }
}(3);

Or ensure the function doesn’t return a value but still calls a callback:

function middleware(nextCallback: () => void) {
    if (conditionApplies()) {
        return void nextCallback();
    }
}

Or importantly, ensure a function returns undefined (used for security reasons):

button.onclick = () => void doSomething();

void in TS is a subtype of undefined. So when your functions return void, they actually implicitly return undefined. They’re pretty much the same, except void as a return type can be substituted with different types, allowing for advanced callback patterns.

function fetchResults(
	callback: (statusCode: number, results: number[]) => void,
) {
	callback(200, [1, 2, 3]);
}

function handleResults(statusCode: number, results: number[]): void {
	console.log(statusCode, results);
}

fetchResults(handleResults);

function handler(statusCode: number): boolean {
	// Evalate status code ...
	return true;
}

// Works, even if the return type is not void!
fetchResults(handler);

Since the calling function doesn’t expect a return value from the callback, anything goes. TypeScript calls this feature substitutability: the ability to substitute one thing for another, wherever it makes sense.

3.7 Dealing with Error Types in catch Clauses

You can’t annotate explicit error types in try-catch blocks. So annotate with any or unknown and use type predicates.

So the pattern to use is:

try {
    throw new Error("Error");
} catch (error) {
    if (error instanceof Error) {
        console.log(error.message);
    } else if (error instanceof RangeError) {
        console.log(error.message);
    } else if (error instanceof SyntaxError) {
        console.log(error.message);
    } else if (error instanceof TypeError) {
        console.log(error.message);
    } else {
        console.log("Unknown error");
    }
}

Disregard the error types I used, they’re just for showing the pattern. Since all possible values can be thrown, and we can only have one catch per try, the type range of error is broad.

3.8 Creating Exclusive Or Models with Optional never

If you want mutually exclusive parts of a union, but you can’t put a kind property in the objects to differentiate them: use the optional never technique to exclude certain properties.

type SelectBase = {
	options: string[];
};

type SingleSelect = SelectBase & {
	value: string;
    values?: never;
};

type MultiSelect = SelectBase & {
	values: string[];
    value?: never;
};

type SelectProperties = SingleSelect | MultiSelect;

function selectCallback(params: SelectProperties) {
	if ("value" in params) {
		// single case
	} else if ("values" in params) {
		// multi case
	}
}

selectCallback({
	options: ["a", "b", "c"],
	value: "a",
});

selectCallback({
	options: ["a", "b", "c"],
	values: ["a", "b"],
});

Now TypeScript will give you an error when you try to put values in a SingleSelect object, or value in a MultiSelect.

3.9 Effectively Using Type Assertions

When TypeScript infers a broad type that you know isn’t as narrow as it could be, you can use the as keyword (unsafe) to narrow it down.

type Dice = 1 | 2 | 3 | 4 | 5 | 6;

function rollDice(): Dice {
    const num = Math.floor(Math.random() * 6) + 1;
    return num as Dice;
};

const result = rollDice();

Type assertions only work within the supertypes and subtypes of an assumed type. You can either set the value to a wider supertype or a narrower subtype.

Type assertions aren’t the same as type casts, despite people sometimes calling it that. A type case not only changes the set of compatible types but also changes the memory layout and even the values themselves. Casting a float to an int cuts off the mantissa. With type assertions, the value stays the same, you just say it’s something else.

When using fetch, it may be more suitable to use type assertions than type annotations:

type Person = {
	name: string;
	age: number;
};

const people: Person[] = await fetch("/api/people").then((res) => res.json());
const people2 = (await fetch("/api/people").then((res) =>
	res.json(),
)) as Person[];

While it’s the same for the type system, it may be easier to spot problems this way. You cannot guarantee that the API returns Person[] (it may change), but you can assert that it does. The assertion indicates it’s an unsafe operation.

3.10 Using Index Signatures

Define a shape that has a yet-to-be-defined set of keys, but a specific value type:

type MetricCollection = {
	[domain: string]: Timings | undefined;
}

We’re using | undefined as well because it’s more accurate – you can index with all possible strings, but there may not be a value.

You can set it as optional by saying domain is not the entire set ofstring, but a subset thereof with a mapped type:

type MetricCollection = {
    [domain in string]?: number;
}

You can define index signatures for everything that’s a valid property key (string, number, or symbol). And with mapped types, everything that’s a subset of those:

type Throws = {
    [key in "rock" | "paper" | "scissors"]: number;
}

You can even add properties to your types. Just make sure the broader set of your index property includes the types from the specific properties.

This is OK because there’s no overlap between the number index signature and the string keys

type ElementCollection = {
    [y: number]: HTMLElement | undefined;
    get(index: number): HTMLElement | undefined;
    length: number;
    filter(predicate: (element: HTMLElement) => boolean): ElementCollection;
}

This is not OK because property count of type number is not assignable to string index type string.

type StringDict = {
    [index: string]: string;
    count: number;
}

3.11 Distinguishing Missing Properties and Undefined Values

Activate exactOptionalPropertyTypes in tsconfig to enable stricter handling of optional properties.

3.12 Working with Enums

Prefer const enums, know their caveats, and maybe choose union types instead.

Enums in TS are a syntactic extension to JS. Not only do they work on a type system level, they also emit JS code.

When you define your enums as const enum, TS tries to substitute the usage with the actual values, getting rid of the emitted code. The additional code could otherwise lead to huge overhead.

Enums are by default numeric, so variants of the enum will have a numeric value assigned, starting at 0. You can change the starting point and actual values of the enum by assigning different values.

You can also use strings instead of numbers for the enum variants, but you’ll have to define those yourself.

Unlike all other types in TS, string enums are nominal types. Two enums with the same set of values are not compatible. Since TS 5.0, the same is the case for number enums.

I strongly prefer union types over enums. Or const objects.

function printUserRole(role: Status | Status1 | TStatus) {
	console.log(role);
}

enum Status {
	Admin = "Admin",
	User = "User",
	Moderator = "Moderator",
}

printUserRole(Status.Admin);

type Status1 = "Admin" | "User" | "Moderator";
printUserRole("Admin");

const Status2 = {
	Admin: "Admin",
	User: "User",
	Moderator: "Moderator",
} as const;
printUserRole(Status2.Admin);

// You can even get the const values of Status when using the object:
type TStatus = (typeof Status2)[keyof typeof Status2];

3.13 Defining Nominal Types in a Structural Type System

What if you have multiple types that use the same set of values as primitive types, but you don’t want to mix them up? Since TypeScript’s type system is structural, types with similar shapes will be compatible.

You can mimic nominal types within the type system. Basically, separate the sets of possible values with distinct properties just enough so that the same values don’t fall into the same set.

One way to do so is by using wrapping classes. Instead of working with the values directly, wrap each in a class, and use a private kind property:

class Balance {
    private kind = "balance";
    value: number;

    constructor(value: number) {
        this.value = value;
    }
}

class AccountNumber {
    private kind = "accountNumber";
    value: number;

    constructor(value: number) {
        this.value = value;
    }
}

In classes, if private or protected members are present, TS considers two types compatible if they originate from the same declaration – otherwise they aren’t. So you can actually just do this instead:

class Balance1 {
    private _nominal: void = undefined;
    value: number;

    constructor(value: number) {
        this.value = value;
    }
}

class AccountNumber1 {
    private _nominal: void = undefined;
    value: number;

    constructor(value: number) {
        this.value = value;
    }
}

But the downside here is that we’re wrapping the original type.

So you can instead intersect the primitive type with a branded object type:

type Credits = number & { _nominal: "credits" };
type AccountID = number & { _nominal: "accountID" };

const accountID = 123 as AccountID;
let balance = 123 as Credits;
const amount = 3000 as Credits;

function increase(balance: Credits, amount: Credits): Credits {
    return (balance + amount) as Credits;
}

balance = increase(balance, amount);
// This is not allowed:
balance = increase(balance, accountID);

3.14 Enabling Loose Autocomplete for String Subsets

This is for cases where you want to have autocomplete for some strings, but to still allow any string.

Add string & {} to your union type of string literals.

type ContentType = "post" | "page" | "asset" | string & {};

You can’t do just | string, as that would have string basically swallow the literals. & {} prevents that.

4. Generics

To write functions where the following parameter(s) are dependent on previous parameters, annotate each parameter with a generic type and create a relationship between them with generic constraints.

type URLList = {
    [key: string]: URL;
}

const languages: URLList = {
  de: new URL("https://bagerbach.com/de"),
  en: new URL("https://bagerbach.com/en"),
  pt: new URL("https://bagerbach.com/pt"),
  es: new URL("https://bagerbach.com/es"),
  fr: new URL("https://bagerbach.com/fr"),
  ja: new URL("https://bagerbach.com/ja"),
};

function foo<List extends URLList>(urls: List, key: keyof List) {
    return urls[key];
}

const site = foo(languages, "en");
console.log(site);

Define generic type parameter List with the constraint that it extends URLList. You could likewise define key as (keyof List)[] to accept multiple keys of List.

We extend the code even further to take the Keys[] and return an array of tuples with the appropriate Keys and data. With this implementation, the returned value will have the type we expect: the first element in the tuple is one of Keys, and the second is data.

function bar<List extends URLList, Keys extends keyof List>(
	urls: List,
	keys: Keys[],
) {
	const r = keys.map(async (el) => {
		const res = await fetch(urls[el]);
		const data: string = await res.text();
		const entry: [Keys, typeof data] = [el, data];
		return entry;
	});

	return r;
}

const sites = bar(languages, ["en", "de"]);
// sites is of type Promise<["de" | "en", string]>[]`

4.5 Generating New Object Types

I’m just noting down one of the examples because I thought it was interesting.

type Group1<
	Collection extends Record<string, any>,
	Selector extends keyof Collection,
> = {
	[x in Collection[Selector]]: Collection[];
};

type Group2<Collection, Selector extends keyof Collection> = {
	[k in Collection[Selector] extends string
		? Collection[Selector]
		: never]?: Collection[];
};

type ToyBase = {
	name: string;
	description: string;
	minimumAge: number;
};

type BoardGame = ToyBase & {
	kind: "boardgame";
	players: number;
};

type Puzzle = ToyBase & {
	kind: "puzzle";
	pieces: number;
};

type Toy = BoardGame | Puzzle;
type GroupedToys = Group2<Toy, "kind">;

function groupToys(toys: Toy[]): GroupedToys {
	const groups: GroupedToys = {};

	for (const toy of toys) {
		groups[toy.kind] = groups[toy.kind] ?? [];
		groups[toy.kind]?.push(toy);
	}

	return groups;
}

I prefer Group2 because it doesn’t use any.

4.6 Modifying Objects with Assertion Signatures

function assert(condition: unknown, msg?: string): asserts condition {
	if (!condition) throw new Error(msg);
}

function yell(str: unknown) {
	assert(typeof str === "string", "str must be a string");
	// str is a string
	console.log(str.toUpperCase());
}

Just beware that this short-circuits if the condition is false.

function check2<T>(obj: T): asserts obj is T & { checked: true } {
	(obj as T & { checked: boolean }).checked = true;
}

() => {
	const person = {
		name: "John",
		age: 20,
	};

	check2(person);
	// TypeScript knows that `person.checked` is true.
	console.log(person.checked);
};

4.7 Mapping Types with Type Maps

This is how document.createElement is typed:

function createElement<T extends keyof HTMLElementTagNameMap>(
    tag: T,
    props?: Partial<HTMLElementTagNameMap[T]>,
): HTMLElementTagNameMap[T] {
    const element = document.createElement(tag);
    return Object.assign(element, props);
}

Where HTMLElementTagNameMap is a huge type map available via lib.dom.ts:

interface HTMLElementTagNameMap {
    "a": HTMLAnchorElement;
    "abbr": HTMLElement;
    "address": HTMLElement;
    // And a lot more elements!
}

If you want to extend createElement to add all possible strings as elements, you can use declaration merging to extend the interface with an indexed signature, where you can map all remaining stings to HTMLUnknownElement:

interface HTMLElementTagNameMap {
    [x: string]: HTMLUnknownElement;
}

But that kills autocomplete.

4.9 Adding Const Context to Generic Type Parameters

Add a const modifier in front of your generic type parameter to keep the passed values in the const context.

The example here is how to create the (basic) typing for a SPA framework’s router. We only get autocomplete on navigate because we used const T extends Route. Otherwise, it would broaden the type of path to string, even though we clearly specify the routes we’re dealing with.

interface ComponentConstructor {
	new (): Component;
}

interface Component {
	render(): HTMLElement;
}

type Route = {
	path: string;
	component: Component;
};

function router<const T extends Route>(routes: T[]) {
	return {
		navigate(path: T["path"]) {
			// ...
		},
	};
}

const Main: Component = {
	render() {
		return document.createElement("div");
	},
};

const About: Component = {
	render() {
		return document.createElement("div");
	},
};

const rtr = router([
	{
		path: "/",
		component: Main,
	},
	{
		path: "/about",
		component: About,
	},
]);

rtr.navigate("/")

5. Conditional Types

Conditional types let us select types based on subtype checks. Essentially, we check if a generic type is a type parameter of a certain subtype. If it is, we return the type from the true branch, otherwise we return the one from the false branch. This is what makes TypeScript’s type system Turing complete.

6. String Template Literal Types

A value is also a type. It’s called a literal type. When in union with other literal types, you can define types that are clear about the values it accepts.

6.1 Defining a Custom Event System

import { createBuilderStatusReporter } from "typescript";

type EventName = `on${string}`;

type EventObject<T> = {
	val: T;
};

type Callback<T = any> = (event: EventObject<T>) => void;

type Events = {
	[x: EventName]: Callback[] | undefined;
};

class EventSystem {
	events: Events;

	constructor() {
		this.events = {};
	}

	defineEventHandler(event: EventName, cb: Callback): void {
		this.events[event] = this.events[event] ?? [];
		this.events[event]?.push(cb);
	}

	trigger(event: EventName, value: any) {
		const callbacks = this.events[event];
		if (callbacks) {
			for (const callback of callbacks) {
				callback({ val: value });
			}
		}
	}
}


const system = new EventSystem();

// @ts-expect-error
system.defineEventHandler("click", () => {});

system.defineEventHandler("onClick", () => {});
system.defineEventHandler("onchange", () => {});

7. Variadic Tuple Types

A variadic tuple type is a tuple type with a defined length & types for each element, but the exact shape is undefined. There’ll be types, but we don’t know which yet.

7.1 Typing a concat Function

Variadic tuple types can happen anywhere in the tuple, and multiple times.

// This is a tuple type:
type PersonProps = [string, number];

// This is a variadic tuple type:
type Foo<T extends unknown[]> = [string, ...T, number];

type T1 = Foo<[boolean]>; // [string, boolean, number]
type T2 = Foo<[number, number]>; // [string, number, number, number]
type T3 = Foo<[]>; // [string, number]

// And this is also a variadic tuple type:
type Bar<T extends unknown[], U extends unknown[]> = [...T, string, ...U];

So we can type concat like:

function concat<T extends unknown[], U extends unknown[]>(
	arr1: [...T],
	arr2: [...U],
): [...T, ...U] {
	return [...arr1, ...arr2];
}

const test = concat([1, 2, 3], [6, 7, "a"]);
// const test: [number, number, number, number, number, string]

7.7 Splitting All Elements of a Function Signature

Parameters<F> and ReturnType<F> are super useful. You can grab the argument and return types from functions with them.

8. Helper Types

  • Partial<T> modifies all properties to be optional.
  • Exclude<T, U> is a conditional type that compares two sets. If elements from T are in U, they’ll be removed with never. Otherwise, they stay.
  • Pick<T, K> selects keys K from object T.
  • Omit<T, K> selects everything but K from T
type DeepPartial<T> = T extends object
  ? {
      [K in keyof T]?: DeepPartial<T[K]>;
    }
  : T;

type Simplify<T> = {
	[K in keyof T]: T[K];
};

// Splits an object into a union of one-property objects.
// So you ensure at least one parameter is provided.
type Split<T> = {
	[K in keyof T]: {
		[P in K]: T[P];
	};
}[keyof T];

// Only one parameter is provided.
type ExactlyOne<T> = {
	[K in keyof T]: {
		[P in K]: T[P];
	} & {
		[P in Exclude<keyof T, K>]?: never;
	};
}[keyof T];

These helper types don’t really deal with edge-cases. Use type-fest instead of defining your own.

9. The Standard Library and External Type Definitions

9.1 Iterating over Objects with Object.keys

type Person = {
	name: string;
	age: number;
};

function printPerson<T extends Person>(p: T) {
    for (const k in p) {
        console.log(k, p[k]);
    }
}

9.2 Explicitly Highlighting Unsafe Operations with Type Assertions and unknown

You may want to use declaration merging on Body (from lib.dom.d.ts) to make it safer:

interface Body {
	json(): Promise<unknown>;
}

Which will have TS notify you that you can’t assign res.json() to a specific type:

const ppl: Person[] = await fetch("/api/people").then((res) => res.json());
//    ^
// Type 'unknown' is not assignable to type 'Person[]'.ts(2322)

10. TypeScript and React

10.1 Writing Proxy Components

Use JSX.IntrinsicElements["button"] to get the button element’s property types.

type ButtonProps = JSX.IntrinsicElements["button"];

function Button(props: ButtonProps) {
	return <button type="button" {...props} />;
}

type can be overwritten (because the spread is after). You can use Omit to remove it from ButtonProps if that is undesirable.

10.7 Typing Callbacks in React’s Synthetic Event System

You can use e.g. React.MouseEvent. There are a bunch more, like AnimationEvent and KeyboardEvent. I won’t enumerate them all here.

11. Classes

11.1 Choosing the Right Visibility Modifier

You can use either public, protected, private, or the JS # prefix.

Prefer the JS-native syntax as it has runtime implications you might want. If you rely on a complex setup that involves variations of visibility modifiers, use the TS ones.

Prefixing methods or properties with # makes them private fields. They cannot be accessed in runtime code, and aren’t enumerable.

11.2 Explicitly Defining Method Overrides

It’s a good idea to switch on noImplicitOverride in your tsconfig.

11.9 Writing Decorators

function log<This, Args extends any[], Return>(
	value: (this: This, ...args: Args) => Return,
	context: ClassMethodDecoratorContext,
) {
	return function (this: This, ...args: Args) {
		console.log(`calling ${context.name.toString()}`);
		return value.call(this, ...args);
	};
}


class Toggler {
    #toggled = false;

    @log
    toggle() {
        this.#toggled = !this.#toggled;
    }
}

const toggler = new Toggler();
toggler.toggle();

There are more decorators. See lib.decorator.d.ts.

And you’ll need to write different functions (or probably use overloads) to log fields, methods, etc.

12. Type Development Strategies

12.1 Writing Low Maintenance Types

For most developers using TS, the goal isn’t to write pure TS all the time, but rather to augment JS.

Ideally we spend less time typing things yet still get the benefits.

Write JS, add extra type annotations when TS required it. It’s better to create types that can update themselves if their dependencies or surroundings change – that’s low-maintenance types.

Creating these is a three-part process:

  1. Model your data or infer from existing models
  2. Define derivatives (mapped types, partials, etc.)
  3. Define behavior with conditional types

12.11 Knowing When to Stop

Problem: Writing elaborate and complicated types is exhausting!

Solution: Don’t write elaborate and complicated types. TypeScript is gradual; use what makes you productive.

Liked these notes? Join the newsletter.

Get notified whenever I post new notes.