MENU
7. TypeScript
JavaScript reports a misspelled property or a missing argument only when the line runs, often in a user's browser. TypeScript adds a static type system: you describe the shape of your data, and the compiler flags mistakes as you type. The types are erased at build time, leaving the JavaScript of Chapter 6.This chapter follows the TypeScript Handbook from everyday types and narrowing to generics, conditional and mapped types, then covers classes, modules, declaration files and tsconfig.json. It targets TypeScript 7.0, the native compiler released in July 2026. Every example was checked with the real compiler, and error messages appear exactly as tsc prints them.What you will learn:Why teams adopt TypeScript, how the compiler works and how to run .ts files in Node.js, Deno and Bun
Everyday types, narrowing, functions, object types and type manipulation
Classes, modules, declaration files and project configuration
Migration, Zod, typescript-eslint, and what changed from TypeScript 5.0 to 7.0
7.1 Introduction
TypeScript is a superset of JavaScript: JavaScript syntax is also TypeScript syntax, extended with annotations, interfaces and generics. Microsoft released version 0.8 in October 2012; its designer, Anders Hejlsberg, also created Turbo Pascal and C#. In August 2025 TypeScript overtook Python and JavaScript as the most used language on GitHub (Octoverse 2025). Runtime semantics (scopes, prototypes, the event loop) belong to Chapter 6; this section covers what types buy you, the 6.0 and 7.0 compilers, their internals and the tools that run .ts files.7.1.1 Why TypeScript?
JavaScript is dynamically typed: a variable's type is known only at run time, and reading a property that does not exist quietly produces undefined (Section 6.8). Arithmetic on undefined then yields NaN, and the bug surfaces far from its cause. This shopping-cart function receives an item whose quantity property was named quantity instead of qty:function total(items) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
console.log(total([{ price: 9.5, quantity: 2 }]));
interface Item {
price: number;
qty: number;
}
function total(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
console.log(total([{ price: 9.5, quantity: 2 }]));
$ npx tsc --noEmit cart.ts
cart.ts(10,34): error TS2353: Object literal may only specify known properties, and 'quantity'
does not exist in type 'Item'.
7.1.2 TypeScript 6.0 and the Native Go-based TypeScript 7.0 Compiler
Through version 6.0 the compiler was written in TypeScript and ran on Node.js. In March 2025 Microsoft announced a port to Go, and two 2026 releases complete the move:TypeScript 6.0 (March 23, 2026) is the last release built on the JavaScript codebase. It is a bridge: strict is on by default, target defaults to es2025, module to esnext, and types to [] (no automatic @types packages). Legacy options are deprecated; "ignoreDeprecations": "6.0" silences the errors for now.
TypeScript 7.0 (July 8, 2026) is the Go port, published as the same typescript npm package with the same tsc command. Its type checker is structurally identical to 6.0's, so it reports the same errors, but native code and shared-memory multithreading make full builds typically 8 to 12 times faster.
$ npx tsc --noEmit
tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
tsconfig.json(4,25): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.
tsconfig.json(5,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
Use '"paths": {"*": ["./src/*"]}' instead.
7.1.3 How the Compiler Works Under the Hood
tsc is a pipeline of five phases, each consuming the previous one's output. In TypeScript 6.0 each phase is one file in src/compiler/; the Go port keeps the design as packages under internal/.
Figure 7.1: The TypeScript compiler pipelineThe scanner turns characters into tokens (interface, Item, {), skipping whitespace and comments.
The parser builds an abstract syntax tree (AST) of nodes such as InterfaceDeclaration, recovering from syntax errors so your editor keeps working.
The binder creates a symbol for each declaration, links names to scopes and builds the control-flow graph that narrowing (Section 7.4) uses.
The checker computes types lazily, when something asks, and compares them. At 54,434 lines in 6.0, checker.ts holds most of the language.
The emitter erases types, downlevels syntax for older target values and writes JavaScript, declaration files and source maps.
$ npx tsc --extendedDiagnostics --outDir dist cart.ts | grep -E "Files|Memory used|time"
Files: 83
Memory used: 65514K
Parse time: 0.033s
Bind time: 0.011s
Check time: 0.238s
Emit time: 0.002s
Total time: 0.286s
7.1.4 Installing and Running
Install the compiler per project so everyone uses the same version: npm install -D typescript, then npx tsc --init writes a starter tsconfig.json (Section 7.12). npx tsc checks the project and emits JavaScript; npx tsc --noEmit only checks. You can also skip the build step and run .ts files directly, because several runtimes now strip types on the fly (runtimes are introduced in Section 6.1.2):$ node status.ts 2>&1 | grep SyntaxError
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
$ npx tsx status.ts
1
$ deno run status.ts
1
$ bun status.ts
1
7.2 The Basics
The compiler tsc does two independent jobs: it checks your program against the types it can see, and it emits JavaScript with the types removed. Keeping those jobs apart in your head explains most of TypeScript's behavior.In JavaScript, user.nmae is simply undefined and Math.random < 0.5 quietly compares a function with a number (Section 6.2.3). The checker reads the source without running it and reports such non-exception failures.const user = { name: "Ada", born: 1815 };
function age(person: { born: number }, year) {
return year - person.born;
}
console.log(user.nmae); // typo
if (Math.random < 0.5) console.log("heads"); // forgot the ()
const box = document.querySelector("input"); // may be null
box.value = String(age(user, 2026));
basics.ts(3,40): error TS7006: Parameter 'year' implicitly has an 'any' type.
basics.ts(6,18): error TS2339: Property 'nmae' does not exist on type '{ name: string; born:
number; }'.
basics.ts(7,5): error TS2365: Operator '<' cannot be applied to types '() => number' and
'number'.
basics.ts(9,1): error TS18047: 'box' is possibly 'null'.
export function retries(opts?: { max?: number }): number {
const max = opts?.max;
return max ?? 2 ** 3;
}
export function retries(opts) {
const max = opts === null || opts === void 0 ? void 0 : opts.max;
return max !== null && max !== void 0 ? max : Math.pow(2, 3);
}
7.3 Everyday Types
Each type describes a set of the JavaScript values from Chapter 6: number is every number, "GET" is one string, string | null is every string plus null. A small vocabulary covers most code.let title: string = "TypeScript"; // annotation (redundant here)
let count = 42; // inferred: number
const tags = ["ts", "js"]; // inferred: string[]
function average(values: number[]): number {
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
tags.forEach((tag) => console.log(tag.toUpperCase())); // tag: string
count = "many";
average(tags);
title.toUppercase();
everyday.ts(10,1): error TS2322: Type 'string' is not assignable to type 'number'.
everyday.ts(11,9): error TS2345: Argument of type 'string[]' is not assignable to parameter of
type 'number[]'.
Type 'string' is not assignable to type 'number'.
everyday.ts(12,7): error TS2551: Property 'toUppercase' does not exist on type 'string'. Did
you mean 'toUpperCase'?
type Id = number | string; // type alias for a union
interface User {
id: Id;
name: string;
email?: string; // optional: string | undefined
readonly createdAt: Date;
}
interface User { roles: string[] } // interfaces merge; aliases cannot
function label(user: User): string {
const mail = user.email.toLowerCase();
return `${user.name} #${user.id.toFixed(0)}`;
}
const ada: User = { id: 1, name: "Ada", createdAt: new Date(), roles: [], age: 36 };
ada.createdAt = new Date();
type Id = bigint;
objects.ts(1,6): error TS2300: Duplicate identifier 'Id'.
objects.ts(12,16): error TS18048: 'user.email' is possibly 'undefined'.
objects.ts(13,35): error TS2339: Property 'toFixed' does not exist on type 'Id'.
Property 'toFixed' does not exist on type 'string'.
objects.ts(16,75): error TS2353: Object literal may only specify known properties, and 'age'
does not exist in type
'User'.
objects.ts(17,5): error TS2540: Cannot assign to 'createdAt' because it is a read-only
property.
objects.ts(18,6): error TS2300: Duplicate identifier 'Id'.
type Method = "GET" | "POST";
declare function send(url: string, method: Method): void;
let loose = "GET"; // widened to string
const req = { url: "/api", method: "GET" };
const frozen = { url: "/api", method: "GET" } as const;
send(req.url, loose);
send(req.url, req.method);
send(frozen.url, frozen.method); // OK: method is "GET"
const canvas = document.getElementById("chart") as HTMLCanvasElement;
const input = document.querySelector("input")!; // trust me: not null
const n = "42" as number;
const data: unknown = JSON.parse('{"x":1}');
data.x;
const risky: any = JSON.parse('{"x":1}');
risky.y.z(); // no error, crashes at runtime
literals.ts(8,15): error TS2345: Argument of type 'string' is not assignable to parameter of
type 'Method'.
literals.ts(9,15): error TS2345: Argument of type 'string' is not assignable to parameter of
type 'Method'.
literals.ts(14,11): error TS2352: Conversion of type 'string' to type 'number' may be a
mistake because neither type
sufficiently overlaps with the other. If this was intentional, convert the expression to
'unknown' first.
literals.ts(17,1): error TS18046: 'data' is of type 'unknown'.
7.4 Narrowing
A union type says what a value might be; before you call toFixed or trim you must prove which member you have. TypeScript follows every if, return, switch, && and assignment in a function (control flow analysis) and gives a variable a narrower type in each branch that ordinary JavaScript checks make safe. Nothing is added at runtime: the checks are the ones Section 6.4.1 already describes, and the compiler reads them.
Figure 7.2: Control flow analysis narrows value at each check in
describe()Each return removes a member from the rest of the function, so by the
last line of describe only number is left. A type
predicate such as x is string lets your own function act as a
guard; since TypeScript 5.5 the compiler also infers one for
simple arrow functions, so filter((n) => n !== undefined) returns
number[].function describe(value: string | number | Date | null | undefined): string {
if (value == null) return "nothing"; // null | undefined
if (typeof value === "string") return `text "${value.trim()}"`;
if (value instanceof Date) return `year ${value.getFullYear()}`;
return `number ${value.toFixed(1)}`; // only number is left
}
const isText = (x: unknown): x is string => typeof x === "string";
const mixed: unknown[] = ["a", 1, "b", null];
const texts = mixed.filter(isText); // string[]
const counts = [3, undefined, 0].filter((n) => n !== undefined); // number[]
const label = (width?: number) => (width ? `${width}px` : "auto"); // 0 is falsy!
console.log(describe(null), describe(" hi "), describe(new Date(2026, 8)), describe(Math.PI));
console.log(texts, counts);
console.log(label(120), label(undefined), label(0));
nothing text "hi" year 2026 number 3.1
[ 'a', 'b' ] [ 3, 0 ]
120px auto auto
type FetchState =
| { status: "loading"; startedAt: number }
| { status: "success"; data: string[] }
| { status: "error"; error: Error; retries: number };
function render(state: FetchState): string {
switch (state.status) { // status is the discriminant
case "loading":
return `Loading since ${state.startedAt}`;
case "success":
return state.data.join(", "); // state: { status: "success"; data: string[] }
case "error":
return `${state.error.message} (retry ${state.retries})`;
default:
return assertNever(state); // state: never
}
}
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
console.log(render({ status: "success", data: ["ts", "js"] }));
console.log(render({ status: "error", error: new Error("Timeout"), retries: 2 }));
ts, js
Timeout (retry 2)
7.5 More on Functions
Functions are values in JavaScript: you pass them as callbacks, store them in objects and call them with a borrowed this (Section 6.5). TypeScript gives each of those roles a type.type Formatter = (value: number) => string;
type Validator = {
message: string; // a property...
(input: string): boolean; // ...plus a call signature
};
const euro: Formatter = (v) => `â¬${v.toFixed(2)}`; // v: number from context
function isEmail(s: string) { return s.includes("@"); }
isEmail.message = "Enter a valid email address"; // expando property
const check: Validator = isEmail; // OK
const bad: Formatter = (v: string) => v;
sigs.ts(11,7): error TS2322: Type '(v: string) => string' is not assignable to type
'Formatter'.
Types of parameters 'v' and 'value' are incompatible.
Type 'number' is not assignable to type 'string'.
function greet(name: string, greeting = "Hello", punct?: string): string {
return `${greeting}, ${name}${punct ?? "!"}`; // punct: string | undefined
}
function sum(first: number, ...rest: number[]): number {
return rest.reduce((acc, n) => acc + n, first);
}
const pair = [8, 5] as const; // readonly [8, 5]
const loose = [8, 5]; // number[]
console.log(greet("Ada"), sum(1, 2, 3), Math.atan2(...pair).toFixed(3));
greet();
Math.atan2(...loose);
const lengths: number[] = [];
["a", "bb"].forEach((s) => lengths.push(s.length)); // push returns a number: OK
function reset(): void { return lengths.length; }
params.ts(11,1): error TS2554: Expected 1-3 arguments, but got 0.
params.ts(12,12): error TS2556: A spread argument must either have a tuple type or be passed
to a rest
parameter.
params.ts(16,26): error TS2322: Type 'number' is not assignable to type 'void'.
function first<T>(items: T[]): T | undefined { return items[0]; }
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
const n = first([3, 1, 2]); // number | undefined
const word = longest("tea", "coffee"); // T is string
longest(10, 20);
generic.ts(7,9): error TS2345: Argument of type 'number' is not assignable to parameter of type
'{ length: number; }'.
function parseDate(timestamp: number): Date;
function parseDate(year: number, month: number, day: number): Date;
function parseDate(iso: string): Date;
function parseDate(a: number | string, month?: number, day?: number): Date {
if (typeof a === "string") return new Date(a);
return month !== undefined && day !== undefined ? new Date(a, month - 1, day) : new Date(a);
}
console.log(parseDate(2026, 9, 15).getMonth()); // 8 (months count from 0)
parseDate(2026, 9);
declare const input: number | string;
parseDate(input);
overload.ts(10,1): error TS2575: No overload expects 2 arguments, but overloads do exist that
expect either
1 or 3 arguments.
overload.ts(12,11): error TS2769: No overload matches this call.
The last overload gave the following error.
Argument of type 'string | number' is not assignable to parameter of type 'string'.
Type 'number' is not assignable to type 'string'.
interface Counter { count: number; step: number }
function increment(this: Counter, times = 1): number {
this.count += this.step * times;
return this.count;
}
const counter = { count: 0, step: 5, increment };
counter.increment(2); // OK: this is counter
const detached = counter.increment;
detached();
document.querySelector("button")?.addEventListener("click", function () {
this.disabled = true; // this: HTMLButtonElement
});
function legacy() { return this.count; }
this.ts(9,1): error TS2684: The 'this' context of type 'void' is not assignable to method's
'this' of type
'Counter'.
this.ts(14,28): error TS2683: 'this' implicitly has type 'any' because it does not have a type
annotation.
7.6 Object Types
Section 7.3 introduced object types, interface and type. The details below decide whether an object type really protects you: how far readonly reaches, what dictionaries return for missing keys, when extra properties are rejected and how types combine. Every rule is about the shape of a value, never the constructor that made it.readonly stops reassignment of the property itself but is shallow: nested objects stay mutable. A readonly string[] (or ReadonlyArray<string>) removes push, splice and index assignment. It is a compile-time promise only, and assignability ignores it, so a mutable alias can still change the property; use Object.freeze (Section 6.3.11) for runtime protection.interface Settings {
readonly theme: { dark: boolean };
readonly tags: readonly string[];
fontSize?: number; // number | undefined
}
const s: Settings = { theme: { dark: false }, tags: ["ts"] };
s.theme.dark = true; // OK: readonly is shallow
s.theme = { dark: true };
s.tags.push("js");
const writable: { theme: { dark: boolean } } = s; // readonly ignored here
writable.theme = { dark: false }; // changes s.theme too
s.fontSize = undefined; // allowed by default
modifiers.ts(8,3): error TS2540: Cannot assign to 'theme' because it is a read-only property.
modifiers.ts(9,8): error TS2339: Property 'push' does not exist on type 'readonly string[]'.
interface HttpHeaders {
[name: string]: string; // any string key, string values
"content-type": string; // known properties must fit the signature
retries: number;
}
const stock: Record<string, number> = { apples: 3 }; // same as { [k: string]: number }
const pears = stock.pears; // number, although no pears exist
console.log(pears.toFixed(0));
type Grid = { [cell: `${string}-${number}`]: boolean }; // template-pattern keys
const grid: Grid = { "A-1": true, B2: false };
index.ts(4,3): error TS2411: Property 'retries' of type 'number' is not assignable to 'string'
index type 'string'.
index.ts(11,35): error TS2353: Object literal may only specify known properties, and 'B2' does
not exist in type
'Grid'.
interface ButtonOptions {
label: string;
color?: "primary" | "danger";
}
declare function button(options: ButtonOptions): HTMLButtonElement;
button({ label: "Save", colour: "primary" }); // typo caught
const opts = { label: "Save", colour: "primary" };
button(opts); // no check: not a fresh literal
const theme = {
save: { label: "Save", color: "primary" },
drop: { label: "Delete", color: "danger", icon: "trash" },
} satisfies Record<string, ButtonOptions>;
const c: "primary" = theme.save.color; // OK: literal type kept
excess.ts(7,25): error TS2561: Object literal may only specify known properties, but 'colour'
does not exist in type
'ButtonOptions'. Did you mean to write 'color'?
excess.ts(13,45): error TS2353: Object literal may only specify known properties, and 'icon'
does not exist in type
'ButtonOptions'.
interface Entity { id: number }
interface Timestamped { createdAt: Date }
interface Post extends Entity, Timestamped { title: string }
type Reply = Entity & Timestamped & { body: string };
const reply: Reply = { id: 2, createdAt: new Date(), body: "Nice" };
interface Draft extends Post { id: string }
type Broken = Post & { id: string }; // id: number & string = never
const b: Broken = { id: 3, createdAt: new Date(), title: "x" };
extend.ts(7,11): error TS2430: Interface 'Draft' incorrectly extends interface 'Post'.
Types of property 'id' are incompatible.
Type 'string' is not assignable to type 'number'.
extend.ts(9,21): error TS2322: Type 'number' is not assignable to type 'never'.
type Span = [start: number, end: number, step?: number]; // labeled, optional
type Row = [id: number, ...cells: string[]]; // rest element
const r: Span = [0, 10];
const row: Row = [7, "Ada", "Lovelace"];
const len: 2 | 3 = r.length;
const point = [3, 4] as const; // readonly [3, 4]
function total(values: readonly number[]) {
values[0] = 1;
}
const short: Span = [1];
tuple.ts(9,3): error TS2542: Index signature in type 'readonly number[]' only permits reading.
tuple.ts(11,7): error TS2322: Type '[number]' is not assignable to type 'Span'.
Source has 1 element(s) but target requires 2.
7.7 Type Manipulation
TypeScript lets you compute new types from existing types and values. Instead of writing a UserUpdate type by hand and keeping it in sync with User, you derive it: Partial<Omit<User, "id">>. When User gains a field, every derived type follows, and the compiler flags each place that now needs attention.The type-level operators mirror JavaScript operations you already know from Chapter 6. They run only in the checker and are erased before the code runs (Section 7.2):7.7.1 Generics
A generic is a type with parameters. Array<T>, Promise<T> and Map<K, V> work for any element type while keeping that type connected from input to output. You declare type parameters in angle brackets and usually let TypeScript infer the arguments from the call (Section 7.5). A parameter can carry a constraint (<K extends keyof T>), a default (<T = string>), or, since TypeScript 5.0, the const modifier, which infers literal types without as const. In a typed event emitter, each instance receives a map of event names to payload types, and every on and emit call is checked against it:type Listener<T> = (payload: T) => void;
class Emitter<Events extends Record<string, unknown>> {
private listeners: { [E in keyof Events]?: Listener<Events[E]>[] } = {};
on<E extends keyof Events>(event: E, listener: Listener<Events[E]>): void {
(this.listeners[event] ??= []).push(listener);
}
emit<E extends keyof Events>(event: E, payload: Events[E]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
}
type CartEvents = { added: { sku: string; qty: number }; cleared: undefined };
const cart = new Emitter<CartEvents>();
cart.on("added", (item) => console.log(`${item.qty} x ${item.sku}`));
cart.emit("added", { sku: "TS-BOOK", qty: 2 });
cart.emit("added", { sku: "TS-BOOK" });
cart.on("removed", () => {});
$ npx tsc --noEmit
emitter.ts(17,20): error TS2741: Property 'qty' is missing in type '{ sku: string; }' but
required in type '{ sku: string; qty: number; }'.
emitter.ts(18,9): error TS2345: Argument of type '"removed"' is not assignable to parameter of
type 'keyof CartEvents'.
$ node emitter.ts
2 x TS-BOOK
undefined x TS-BOOK
7.7.2 The keyof and typeof Operators
keyof T produces the union of T's property names as literal types. In a type position, typeof x produces the type TypeScript inferred for the variable x. Combined, they turn an ordinary object into the single source of truth for its keys and value types, as in this settings reader backed by localStorage (Section 9.3.2):const defaults = { theme: "light", pageSize: 20, beta: false };
type Settings = typeof defaults; // { theme: string; pageSize: number; beta: boolean }
type SettingKey = keyof Settings; // "theme" | "pageSize" | "beta"
function readSetting<K extends SettingKey>(key: K): Settings[K] {
const stored = localStorage.getItem(key);
return stored === null ? defaults[key] : JSON.parse(stored);
}
const size = readSetting("pageSize"); // number
size.toFixed(0);
readSetting("colour");
const saved: Settings = { theme: "dark", pageSize: "50", beta: true };
settings.ts(10,13): error TS2345: Argument of type '"colour"' is not assignable to parameter
of type '"beta" | "pageSize" | "theme"'.
settings.ts(11,42): error TS2322: Type 'string' is not assignable to type 'number'.
7.7.3 Indexed Access Types
An indexed access type T[K] looks up the type of a property with the bracket syntax you use to read a value. The index can be a literal, a union of keys, keyof T (giving the union of all value types), or number to get an array's element type. It reaches into large generated types, such as API client responses, without copying nested shapes:type Order = {
id: string;
customer: { name: string; address: { city: string; zip: string } };
items: { sku: string; price: number }[];
};
type Address = Order["customer"]["address"]; // { city: string; zip: string }
type LineItem = Order["items"][number]; // { sku: string; price: number }
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"
const line: LineItem = { sku: "A1", price: "9.99" };
const role: Role = "owner";
order.ts(10,37): error TS2322: Type 'string' is not assignable to type 'number'.
order.ts(11,7): error TS2322: Type '"owner"' is not assignable to type '"admin" | "editor" |
"viewer"'.
7.7.4 Conditional Types
A conditional type T extends U ? X : Y is the type-level ternary: if T is assignable to U, the result is X, otherwise Y. Inside the condition, infer Name declares a type variable that TypeScript fills in by matching. Conditional types arrived in TypeScript 2.8; since 4.1 they may refer to themselves:type Unwrap<T> = T extends Promise<infer V> ? Unwrap<V> : T; // recursive
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
return (await res.json()) as { id: string; name: string };
}
type User = Unwrap<ReturnType<typeof fetchUser>>; // { id: string; name: string }
const guest: User = { id: "0" };
type ToArray<T> = T extends unknown ? T[] : never; // distributive
type ToArrayOnce<T> = [T] extends [unknown] ? T[] : never;
const mixed: ToArrayOnce<string | number> = ["a", 1];
const split: ToArray<string | number> = ["a", 1];
api.ts(7,7): error TS2741: Property 'name' is missing in type '{ id: string; }' but required
in type '{ id: string; name: string; }'.
api.ts(12,7): error TS2322: Type '(string | number)[]' is not assignable to type 'string[] |
number[]'.
Type '(string | number)[]' is not assignable to type 'string[]'.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.
7.7.5 Mapped Types
A mapped type builds an object type by looping over keys: { [K in keyof T]: NewType } visits each property of T the way for...in visits an object, with T[K] as the original property type. The modifiers readonly and ? can be added with + (the default) or removed with -. Partial, Required and Readonly are one-line mapped types; add a conditional type and recursion and you can make a whole configuration tree immutable:type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Flags<T> = { [K in keyof T]-?: boolean }; // -? removes optionality
type AppConfig = { api: { baseUrl: string; retries: number }; hosts: string[]; debug?: boolean };
const config: DeepReadonly<AppConfig> = { api: { baseUrl: "/api", retries: 3 }, hosts: [] };
config.api.retries = 5;
config.hosts.push("b.example.com");
const shown: Flags<AppConfig> = { api: true, hosts: false };
config.ts(7,12): error TS2540: Cannot assign to 'retries' because it is a read-only property.
config.ts(8,14): error TS2339: Property 'push' does not exist on type 'readonly string[]'.
config.ts(9,7): error TS2741: Property 'debug' is missing in type '{ api: true; hosts: false;
}' but required in type 'Flags<AppConfig>'.
7.7.6 Template Literal Types
A template literal type puts the backtick syntax of template strings (Section 6.12.2) in a type position: with type Version = "v1" | "v2", ` /api/${Version} is "/api/v1" | "/api/v2", and several unions in one template multiply into every combination. Four helpers built into the compiler, Uppercase, Lowercase, Capitalize and Uncapitalize, transform the strings. Inside a conditional type, a template with infer` parses a string literal, so a router can read parameter names straight out of a path:type ParamNames<Path extends string> =
Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest>
: Path extends `${string}:${infer Name}` ? Name
: never;
type RouteParams<Path extends string> = Record<ParamNames<Path>, string>;
function buildUrl<P extends string>(path: P, params: RouteParams<P>): string {
return path.replace(/:(\w+)/g, (_, name: ParamNames<P>) => encodeURIComponent(params[name]));
}
console.log(buildUrl("/users/:userId/posts/:postId", { userId: "42", postId: "7" }));
buildUrl("/users/:userId/posts/:postId", { userId: "42" });
$ npx tsc --noEmit
routes.ts(11,42): error TS2741: Property 'postId' is missing in type '{ userId: string; }' but
required in type 'RouteParams<"/users/:userId/posts/:postId">'.
$ node routes.ts
/users/42/posts/7
7.7.7 Utility Types
TypeScript's standard library (lib.es5.d.ts) defines utility types with the operators of this section: Partial is a mapped type, Exclude a conditional type, NoInfer a compiler intrinsic. Learn them before writing your own:interface User { id: number; name: string; email: string; role: "admin" | "member" }
type NewUser = Omit<User, "id">; // a signup form's payload
type UserPatch = Partial<Pick<User, "name" | "email">>; // { name?: string; email?: string }
const roleLabels: Record<User["role"], string> = { admin: "Administrator", member: "Member" };
declare function updateUser(id: number, patch: UserPatch): Promise<User>;
type Updated = Awaited<ReturnType<typeof updateUser>>; // User
function choose<T extends string>(sizes: T[], fallback: NoInfer<T>): T {
return sizes[0] ?? fallback;
}
const draft: NewUser = { id: 7, name: "Ada", email: "ada@example.com", role: "admin" };
updateUser(1, { role: "admin" });
choose(["sm", "md", "lg"], "xl");
users.ts(10,26): error TS2353: Object literal may only specify known properties, and 'id' does
not exist in type 'NewUser'.
users.ts(11,17): error TS2353: Object literal may only specify known properties, and 'role'
does not exist in type 'Partial<Pick<User, "email" | "name">>'.
users.ts(12,28): error TS2345: Argument of type '"xl"' is not assignable to parameter of type
'"lg" | "md" | "sm"'.
7.8 Classes
TypeScript classes are JavaScript classes (Section 6.7) with the same fields, getters, static blocks, #private members and extends chains. What TypeScript adds is a set of modifiers that the checker enforces and the emitter mostly erases. Under strict (the default since TypeScript 6.0) every field must also be initialized in its declaration or the constructor; write name!: string when a framework assigns it later.class Account {
#pin = "0000"; // JavaScript private field
constructor(
public readonly owner: string, // parameter properties
private balance: number,
protected currency = "EUR",
) {}
deposit(amount: number) { return (this.balance += amount); }
}
const acct = new Account("Ada", 100);
console.log(acct.deposit(50), acct["balance"]); // bracket escape hatch compiles
acct.owner = "Eve";
acct.balance = 1e6;
acct.#pin;
account.ts(14,6): error TS2540: Cannot assign to 'owner' because it is a read-only property.
account.ts(15,6): error TS2341: Property 'balance' is private and only accessible within class
'Account'.
account.ts(16,6): error TS18013: Property '#pin' is not accessible outside class 'Account'
because it has a private identifier.
interface Printable {
print(prefix: string): string;
}
abstract class Shape implements Printable {
abstract area(): number; // no body: subclasses must implement
print(prefix: string) { return `${prefix}${this.area().toFixed(2)}`; }
}
class Circle extends Shape {
constructor(private r: number) { super(); }
area() { return Math.PI * this.r ** 2; }
override print(prefix: string) { return `Circle ${super.print(prefix)}`; }
}
console.log(new Circle(1).print("area="));
class Square extends Shape {}
new Shape();
class Label implements Printable {
print(prefix) { return prefix; }
}
class Ring extends Circle {
area() { return 0; }
}
shapes.ts(18,7): error TS2515: Non-abstract class 'Square' does not implement inherited
abstract member area from class 'Shape'.
shapes.ts(19,1): error TS2511: Cannot create an instance of an abstract class.
shapes.ts(21,9): error TS7006: Parameter 'prefix' implicitly has an 'any' type.
shapes.ts(24,3): error TS4114: This member must have an 'override' modifier because it
overrides a member in the base class 'Circle'.
7.9 Modules
You write modules with the import and export syntax of Section 6.2.5, but TypeScript does not load them: Node.js, Deno, Bun or a bundler such as Vite does. Two options tell the compiler how that host behaves. module says which format each output file must use, and moduleResolution says how the host turns a specifier such as "./cart.js" into a file. TypeScript never rewrites your specifiers during emit, so it must reject imports that the real host would fail to find.{ "name": "shop", "type": "module", "imports": { "#/*": "./dist/*" } }
{ "compilerOptions": { "module": "nodenext", "rootDir": "src", "outDir": "dist",
"verbatimModuleSyntax": true, "types": ["node"] } }
// src/lib/legacy.cts (CommonJS)
function tax(amount: number) { return amount * 0.2; }
export = tax;
// src/main.ts (ESM because of "type": "module")
import { format, Money } from "#/lib/money.js"; // subpath import
import tax from "./lib/legacy.cjs"; // default import = module.exports
import { format as f2 } from "./lib/money"; // no extension
const price: Money = { amount: 10, currency: "EUR" };
console.log(format(price), tax(price.amount));
src/main.ts(1,18): error TS1484: 'Money' is a type and must be imported using a type-only
import when 'verbatimModuleSyntax' is enabled.
src/main.ts(3,30): error TS2835: Relative import paths need explicit file extensions in
ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean
'./lib/money.js'?
7.10 Reference Topics
These handbook reference topics appear in existing code more often than in new code. Four generate JavaScript (enums, decorators, namespaces, JSX) and deserve a closer look below; the rest fit in a table.enum Level { Low = 1, High } // numeric: reverse mapping
enum Dir { Up = "UP", Down = "DOWN" } // string: no reverse mapping
const Status = { Active: "active", Archived: "archived" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "active" | "archived"
function move(d: Dir) { return d; }
function setStatus(s: Status) { return s; }
console.log(Level.High, Level[2], move(Dir.Up), setStatus("archived"));
move("UP");
const lvl: Level = 7;
enums.ts(12,6): error TS2345: Argument of type '"UP"' is not assignable to parameter of type
'Dir'.
enums.ts(13,7): error TS2322: Type '7' is not assignable to type 'Level'.
function logged<This, Args extends unknown[], R>(
method: (this: This, ...args: Args) => R,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => R>,
) {
return function (this: This, ...args: Args): R {
console.log(`${String(context.name)}(${args.join(", ")})`);
return method.call(this, ...args);
};
}
class Cart {
@logged add(price: number, qty: number) { return price * qty; }
}
console.log(new Cart().add(4, 3));
add(4, 3)
12
export {}; // make this file a module
interface Box { width: number }
interface Box { height: number } // merged: { width; height }
const box: Box = { width: 2, height: 3 };
declare global {
interface Window { appVersion: string } // augment a lib.dom.d.ts type
}
function money(n: number) { return `$${n.toFixed(2)}`; }
namespace money { // function + namespace merge
export const currency = "USD";
}
console.log(box.width * box.height, money(4), money.currency);
7.11 Declaration Files
A declaration file (.d.ts) holds only types, signatures without bodies, describing JavaScript the compiler cannot see, from lib.dom.d.ts (Chapter 8) to a plain-JavaScript npm package.Beside slugify.js, slugify.d.ts makes slugify(42) in a .ts file fail with error TS2345.export interface SlugifyOptions {
/** Character placed between words. Default "-". */
separator?: string;
}
export declare function slugify(text: string, options?: SlugifyOptions): string;
// @ts-check
/** @typedef {{ name: string, price: number, qty: number }} CartItem */
/** @param {CartItem[]} items @param {number} [taxRate] */
export function total(items, taxRate = 0.08) {
const sum = items.reduce((acc, item) => acc + item.price * item.quantity, 0);
return Math.round(sum * (1 + taxRate) * 100) / 100;
}
total([{ name: "Pen", price: 2, qty: 3 }], "8%");
src/cart.js(6,67): error TS2339: Property 'quantity' does not exist on type 'CartItem'.
src/cart.js(9,44): error TS2345: Argument of type 'string' is not assignable to parameter of
type 'number'.
7.12 Project Configuration
tsconfig.json marks a project root: compilerOptions sets checking and output, include lists the source folders, and extends inherits a shared base (an array of bases since TypeScript 5.0). npx tsc --init writes a short starter file; npx tsc --showConfig prints the final merged settings, including implied ones.strict and target can stay at their TypeScript 6.0 defaults (Section 7.2). The app column follows the create-vite templates; the library column emits JavaScript and .d.ts files for npm.{
"compilerOptions": { "module": "nodenext", "outDir": "dist", "rootDir": "src" },
"references": [{ "path": "../core" }]
}
$ npx tsc -b app --verbose
Project 'core/tsconfig.json' is up to date because newest input 'core/src/index.ts' is older
than output 'core/tsconfig.tsbuildinfo'
Project 'app/tsconfig.json' is up to date because newest input 'app/src/main.ts' is older than
output 'app/dist/main.js'
7.13 TypeScript in Practice
Real projects need typed browser APIs, a gradual path from JavaScript, and tools that check what the compiler cannot.lib.dom.d.ts is generated from the web specifications (Chapter 8). querySelector infers the element type from a tag name; for other selectors you pass it. Listeners get their event type from the event name. Lookups may return null and event.target is only an EventTarget, so narrow with instanceof (Section 7.4):const form = document.querySelector("form"); // HTMLFormElement | null
const qty = document.querySelector<HTMLInputElement>("#qty");
const rows = document.querySelectorAll("tr"); // NodeListOf<HTMLTableRowElement>
form.addEventListener("submit", (event) => { // event: SubmitEvent
event.preventDefault();
rows.forEach((row) => (row.hidden = row.rowIndex > (qty?.valueAsNumber ?? 0)));
});
document.addEventListener("click", ({ target }) => { // target: EventTarget | null
if (target instanceof HTMLButtonElement) target.disabled = true;
target.disabled = false;
});
dom.ts(4,1): error TS18047: 'form' is possibly 'null'.
dom.ts(10,3): error TS18047: 'target' is possibly 'null'.
dom.ts(10,10): error TS2339: Property 'disabled' does not exist on type 'EventTarget'.
import * as z from "zod";
import type { SetRequired } from "type-fest";
const Order = z.object({ email: z.email(), qty: z.int().positive(), note: z.string().optional() });
type Order = z.infer<typeof Order>; // { email: string; qty: number; note?: string }
export type GiftOrder = SetRequired<Order, "note">; // note: string
const result = Order.safeParse(JSON.parse('{"email":"ada@example","qty":0}'));
if (result.success) console.log(result.data.qty); // result.data: Order
else console.log(z.prettifyError(result.error));
â Invalid email address
â at email
â Too small: expected number to be >0
â at qty
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
export default defineConfig({
files: ["**/*.ts"],
extends: [js.configs.recommended, tseslint.configs.recommendedTypeChecked],
languageOptions: { parserOptions: { projectService: true } },
});
5:3 error Promises must be awaited, end with a call to .catch, end with a call to .then
with a rejection handler or be explicitly marked as ignored with the `void` operator
@typescript-eslint/no-floating-promises
import { expectTypeOf } from "expect-type";
import * as z from "zod";
const Order = z.object({ email: z.email(), qty: z.int() });
expectTypeOf<z.infer<typeof Order>>().toEqualTypeOf<{ email: string; qty: number }>();
expectTypeOf(Order.parse).returns.toEqualTypeOf<{ email: string; qty: string }>();
// @ts-expect-error: qty must be a number
Order.parse satisfies (data: unknown) => { qty: string };
types.test.ts(6,49): error TS2344: Type '{ email: string; qty: string; }' does not satisfy the
constraint '{ email: string; qty: "Expected: string, Actual: number"; }'.
7.14 What's New
When you follow an older tutorial or upgrade a project, check which release introduced a feature:function openLog(name: string) {
console.log(`open ${name}`);
return { [Symbol.dispose]: () => console.log(`close ${name}`) };
}
const pick = <T extends string>(options: T[], fallback: NoInfer<T>): T => options[0] ?? fallback;
{
using log = openLog("audit"); // disposed at block end
const ids = [3, undefined, 7].filter((id) => id !== undefined); // number[]
console.log(ids.map((id) => id * 2), pick(["sm", "lg"], "sm"));
pick(["sm", "lg"], "xl");
}
$ npx tsc --noEmit
whatsnew.ts(10,22): error TS2345: Argument of type '"xl"' is not assignable to parameter of
type '"lg" | "sm"'.
$ node whatsnew.ts
open audit
[ 6, 14 ] sm
close audit
7.15 Alternatives and Companions
Three neighbors of TypeScript remain worth knowing: a competitor, a way to use TypeScript without its syntax, and a language that borrows its syntax for WebAssembly.export function fib(n: i32): i64 {
let a: i64 = 0, b: i64 = 1;
for (let i = 0; i < n; i++) {
const t = a + b;
a = b;
b = t;
}
return a;
}
$ node -e "import('./build/fib.js').then(m => console.log(m.fib(90), m.fib(3.9)))"
2880067194370816120n 2n