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 export
s 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 tsconfig
s:
{
"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
4.2 Creating Related Function Arguments
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 fromT
are inU
, they’ll be removed withnever
. Otherwise, they stay.Pick<T, K>
selects keysK
from objectT
.Omit<T, K>
selects everything butK
fromT
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:
- Model your data or infer from existing models
- Define derivatives (mapped types, partials, etc.)
- 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.