9. TypeScript
TypeScript is JavaScript with a static type system: you describe the shape of your values with types, a compiler checks that the code respects them before it runs, and then the types are erased, leaving ordinary JavaScript. Microsoft released it in 2012, and it has since become the default language for large front-end and Node.js code bases, because it turns whole classes of runtime errors (typos, missing properties, unexpected undefined, wrong arguments) into editor warnings and makes refactoring safe. This chapter builds the type system from the ground up (9.1), moves on to the advanced features used to model APIs and libraries (9.2), and ends with TypeScript in real projects: classes, modules, compiler configuration, the build pipeline, and typing the DOM, Node.js and third-party code (9.3).
<a id="91-type-system-basics"></a>
9.1 Type System Basics
This subchapter explains what static typing adds to JavaScript and introduces the building blocks of every TypeScript program: primitive and special types, annotations and inference, collection types, unions and intersections, aliases and interfaces, and literal types with narrowing.
Static vs Dynamic Typing
Every program works with values, and every value has a type: a number, a string, an object with certain properties. Every language has to decide when it checks that an operation makes sense for the values it is applied to.
JavaScript checks at the last possible moment, while the line runs (Chapter 3.1). A typo in a property name is not an error, just an undefined that travels through the program until something tries to use it:
function shippingLabel(order) {
return `${order.customer.name}, ${order.customer.adress.city}`; // typo: "adress"
}
shippingLabel({ customer: { name: 'Ada', address: { city: 'London' } } });
// TypeError: Cannot read properties of undefined (reading 'city')
// ...found only when this line runs: in a test, if one exists, or in production
The same code in TypeScript, with a description of what an order looks like:
interface Order {
customer: { name: string; address: { city: string } };
}
function shippingLabel(order: Order): string {
return `${order.customer.name}, ${order.customer.adress.city}`;
// error TS2551: Property 'adress' does not exist on type '{ name: string; address: { city: string; }; }'.
// Did you mean 'address'?
}
The mistake is underlined in the editor as you type it, before any code has run.
Definitions.
- Dynamic typing: types belong to values at runtime. A variable can hold a number now and a string later, and each operation is checked when it executes.
- Static typing: types also belong to variables, parameters and expressions in the source code, and a checker verifies them before the program runs (at "compile time").
| Aspect | Dynamic typing | Static typing |
|---|---|---|
| When type errors are found | When the faulty line executes | Before the program runs (editor, build, CI) |
| Types are attached to | Values | Variables, parameters, expressions (and values) |
| Typical feedback loop | Tests, logs, bug reports | Editor underlines, compiler errors |
| Tooling | Autocomplete based on guesses | Exact autocomplete, go-to-definition, safe rename |
| Up-front cost | None | Writing and maintaining type annotations |
| Examples | JavaScript, Python, Ruby, PHP | TypeScript, Java, C#, Go, Rust |
A separate axis: strong vs weak. Whether types are checked early or late is independent of how much a language converts values implicitly. JavaScript is dynamic and weak ('5' * 2 is 10), Python is dynamic but strong ('5' * 2 is '55', and '5' + 2 raises an error), and Java is static and strong. TypeScript keeps JavaScript's runtime behaviour, but the checker rejects most suspicious coercions:
const total = '5' * 2;
// error TS2362: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
How TypeScript fits in. TypeScript is a superset of JavaScript: every JavaScript program is syntactically a TypeScript program, and TypeScript adds type syntax on top. The compiler does two independent jobs:
app.ts (JavaScript + type annotations)
|
+--------------+---------------+
| |
Type checker Emitter
(reads the whole program, (deletes the types, optionally
reports type errors) rewrites newer syntax)
| |
errors in the editor / CI app.js --> runs in the browser or Node.js
(no types left)
Because types are erased, they cost nothing at runtime, but they also check nothing at runtime:
// input: greet.ts
function greet(name: string, times: number = 1): string {
return `Hello, ${name}! `.repeat(times);
}
// output: greet.js -- the same program with the annotations removed
function greet(name, times = 1) {
return `Hello, ${name}! `.repeat(times);
}
The character of TypeScript's type system.
| Property | Meaning | Consequence |
|---|---|---|
| Static | Checked before running | Errors appear while you type |
| Structural | Compatibility depends on shape, not on declared names | Compile-time duck typing (Chapter 4.3) |
| Gradual | any, JavaScript files and partial typing are allowed | Projects can migrate file by file |
| Erased | No type information exists at runtime | typeof and instanceof are JavaScript operators, not type queries |
| Intentionally unsound | A few holes are left open for practicality | A program with no errors can still crash (see below) |
Structural typing is the property that surprises people coming from Java or C#. A value is compatible with a type if it has the required properties, whatever it was declared as:
interface Point {
x: number;
y: number;
}
class Vector {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function magnitude(p: Point): number {
return Math.hypot(p.x, p.y);
}
const labelled = { x: 3, y: 4, label: 'A' };
magnitude(labelled); // OK: has x and y of type number; the extra property is fine
magnitude(new Vector(3, 4)); // OK: Vector never mentions Point, but has the same shape
magnitude({ x: 3 }); // error TS2741: Property 'y' is missing in type '{ x: number; }' but required in type 'Point'.
Analogy: TypeScript is the spelling and grammar checker of a word processor. It underlines mistakes while you write, before the letter is sent, and the printed letter contains no red lines. Like a grammar checker, it cannot tell whether the facts in the letter are true: data that arrives while the program runs (a server response, localStorage, user input) is not checked.
interface Profile {
name: string;
}
const response = await fetch('/api/profile');
const profile = (await response.json()) as Profile; // a claim, not a check
console.log(profile.name.toUpperCase());
// compiles, but crashes if the server actually sent { "fullName": "Ada" }
Two more practical details: type errors do not stop tsc from emitting JavaScript unless you set noEmitOnError, and you can run TypeScript in the browser-based TypeScript Playground (typescriptlang.org/play) to see errors and output side by side.
Key Takeaways
- Dynamic typing checks types at runtime, as each operation executes; static typing checks them before the program runs.
- Static vs dynamic (when types are checked) is independent of strong vs weak (how much implicit conversion happens).
- TypeScript is a superset of JavaScript whose types are checked by the compiler and then erased, so they have no runtime cost and no runtime effect.
- Its type system is structural (shape matters, not names), gradual (
anyand plain JavaScript can be mixed in) and deliberately not fully sound. - Data that enters at runtime (network, storage, user input) is not verified by TypeScript and needs runtime validation.
Practice
- (Beginner) In the TypeScript Playground, paste the JavaScript
shippingLabelfunction, add theOrderinterface and a parameter annotation, and read the error. Then call the function with an object that lackscustomer.nameand read the new error.- (Intermediate) Place JavaScript, TypeScript, Python, Java and C on a two-axis grid (static/dynamic, strong/weak). For each language, write a one-line code example that justifies where you placed it.
- (Interview) "Our TypeScript project compiled with zero errors, yet production crashed with
Cannot read properties of undefined. How is that possible?" Hint: think about erased types,any, type assertions and data that crosses a runtime boundary.
Primitive and Special Types
TypeScript's types mirror JavaScript's runtime types (Chapter 3.1) and add a few types that exist only in the type system to describe "anything", "nothing" and "impossible".
Primitive types. Each JavaScript primitive has a matching lowercase type:
| Type | Values | Example |
|---|---|---|
string | Text | 'hi', `Total: ${n}` |
number | IEEE 754 doubles, including NaN and Infinity | 42, 3.14, -0 |
bigint | Integers of arbitrary size | 9007199254740993n |
boolean | true, false | true |
symbol | Unique identifiers (unique symbol for a specific const one) | Symbol('id') |
undefined | The value undefined | undefined |
null | The value null | null |
Always use the lowercase names. String, Number and Boolean with a capital letter are the types of the rarely used wrapper objects (new String('x')) and should never appear in annotations.
const productName: string = 'Keyboard';
const price: number = 49.9;
const orderId: bigint = 9_007_199_254_740_993n; // larger than Number.MAX_SAFE_INTEGER
const inStock: boolean = true;
const cacheKey: symbol = Symbol('cache');
let discount: number | undefined; // declared now, maybe assigned later
let coupon: string | null = null; // explicitly "no value"
null, undefined and strictNullChecks. Without strictNullChecks, null and undefined can be assigned to every type, which reproduces the "billion-dollar mistake" of Java and C. With it (part of strict, which is on by default in TypeScript 7), absence must be part of the type and handled explicitly:
let title: string = 'Report';
title = null; // error TS2322: Type 'null' is not assignable to type 'string'.
const heading = document.querySelector('h1'); // HTMLHeadingElement | null
heading.textContent = 'Hi'; // error TS18047: 'heading' is possibly 'null'.
if (heading) heading.textContent = 'Hi'; // OK after the check
Special types.
| Type | Meaning | Typical use |
|---|---|---|
any | Switches type checking off for this value | Migration from JavaScript, last-resort escape hatch |
unknown | "Some value, I don't know what yet"; must be checked before use | JSON.parse results, catch variables, untrusted input |
never | No value can ever have this type | Functions that always throw, impossible branches |
void | "The return value should not be used" | Return type of functions without a meaningful result |
object | Any non-primitive (objects, arrays, functions) | Parameters that must be objects |
{} | Any value except null and undefined, including primitives | Rarely what you mean |
These types form a hierarchy. Think of each type as a set of values: unknown contains every value (the top type), never contains none (the bottom type), and everything else lies between them.
unknown top: every value is assignable TO it
|
string number bigint boolean symbol object null undefined
| | | / | \
'GET' 42 true {...} [...] (...) => ... more specific types
\ \ \ \ | /
never bottom: assignable to every type, has no values
any stands outside this picture: it is assignable to and from (almost) everything,
and switches checking off wherever it flows
any vs unknown. Both accept any value; the difference is what you may do with it afterwards.
function parseAny(text: string): any {
return JSON.parse(text);
}
function parseSafe(text: string): unknown {
return JSON.parse(text);
}
const a = parseAny('{"count": 1}');
a.items.map((x: number) => x * 2); // compiles, crashes at runtime: there is no "items"
const n: number = a; // compiles: any is silently assignable to everything
const u = parseSafe('{"count": 1}');
u.count; // error TS18046: 'u' is of type 'unknown'.
if (typeof u === 'object' && u !== null && 'count' in u && typeof u.count === 'number') {
console.log(u.count + 1); // OK: each check narrows the type a little further
}
any | unknown | |
|---|---|---|
| Accepts any value | Yes | Yes |
| Can be assigned to other types | Yes, silently | Only to unknown and any |
| Property access and calls | Allowed without checks | Error until the type is narrowed |
| Spreads to other values | Yes (a.b.c is also any) | No |
| Use it when | You must opt out of checking | You have a value you have not validated yet |
void and never.
function logEvent(name: string): void {
console.log(`[event] ${name}`); // returns undefined; callers should ignore the result
}
function fail(message: string): never {
throw new Error(message); // never returns normally
}
function parsePort(value: string): number {
const port = Number(value);
if (Number.isInteger(port) && port > 0 && port < 65536) return port;
return fail(`Invalid port: ${value}`); // OK: never is assignable to number
}
// A callback typed as returning void may still return a value; it is just ignored.
const ids: number[] = [];
[1, 2, 3].forEach((n) => ids.push(n)); // push returns a number, forEach expects void: allowed
object vs {}.
let o: object = { a: 1 };
o = [1, 2]; // OK: arrays are objects
o = 'text'; // error TS2322: Type 'string' is not assignable to type 'object'.
let e: {} = 'text'; // OK: {} means "not null or undefined", so primitives are accepted
e = null; // error TS2322: Type 'null' is not assignable to type '{}'.
Key Takeaways
- Use the lowercase primitive types
string,number,bigint,boolean,symbol,nullandundefined, never the capitalised wrapper types. - With
strictNullChecks, absence must be written into the type (string | null) and handled before use. unknownis the safe "any value" type: everything is assignable to it, but you must narrow it before using it.anyturns checking off and spreads.neveris the empty type (impossible values, functions that never return);voidmeans "ignore the return value".objectmeans non-primitive;{}means any value exceptnullandundefined.
Practice
- (Beginner) Declare annotated variables for a product price, a 20-digit order number, an availability flag, a unique metadata key and a middle name that may be missing. Try assigning a wrong value to each and read the errors.
- (Intermediate) Write
function toNumber(value: unknown): numberthat accepts numbers, numeric strings and booleans and throws aTypeErrorotherwise, without usingany. Then change the parameter toanyand list the mistakes the compiler no longer catches.- (Interview) "Explain the difference between
any,unknownandnever, and give a situation where each is the right choice." Hint: think of them as "checking off", "top type" and "bottom type", and ask in which direction assignment is allowed for each.
Type Annotations and Inference
TypeScript needs to know the type of every expression, but you do not have to write it everywhere. You annotate where the compiler has nothing to work from or where you want to state a contract, and let it infer the rest.
Analogy: think of labels in a shared kitchen. The jars on the shelf that everyone uses (function signatures, exported values) must be labelled. The spoonful you just took out of a labelled jar (a local variable) obviously contains the same thing, and labelling it again only adds noise.
Annotation syntax. A colon after the name, followed by the type:
let count: number = 0; // variable
const tags: string[] = []; // needed: an empty array gives nothing to infer from
function greet(name: string, greeting = 'Hello'): string { // parameter, default value, return type
return `${greeting}, ${name}`; // greeting is inferred as string from its default
}
function log(message: string, level?: 'info' | 'warn'): void { // optional parameter
console.log(level ?? 'info', message); // level: 'info' | 'warn' | undefined
}
const user: { id: number; email: string; nickname?: string } = { // object type, optional property
id: 1,
email: 'ada@example.com',
};
Where inference comes from.
- Initialisers:
let total = 0makestotalanumber. - Return statements: the return type of a function is inferred from what it returns.
- Contextual typing: the expected type flows into an expression, typically a callback.
- Best common type: an array literal gets the union of its element types.
let total = 0; // number
const prices = [9.99, 25, 4.5]; // number[]
const mixed = [1, 'two', null]; // (string | number | null)[]
function average(values: number[]) { // return type inferred as number
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
// Contextual typing: no annotations on the callbacks, yet everything is typed
window.addEventListener('keydown', (event) => {
console.log(event.key); // event: KeyboardEvent, inferred from 'keydown'
});
const labels = prices.map((p) => p.toFixed(2)); // p: number, labels: string[]
Widening. The inferred type depends on whether the value can change:
| Declaration | Inferred type | Reason |
|---|---|---|
let method = 'GET' | string | A let can be reassigned to any string |
const method = 'GET' | 'GET' | A const can never change, so the literal type is kept |
const req = { method: 'GET' } | { method: string } | Object properties are mutable, so they widen |
const req = { method: 'GET' } as const | { readonly method: 'GET' } | as const keeps literals and makes everything readonly |
When to annotate.
| Situation | Annotate? | Why |
|---|---|---|
| Function parameters | Yes, unless contextually typed | Nothing to infer from; otherwise implicit any (an error under noImplicitAny) |
| Return types of exported functions | Recommended | Documents the contract and catches accidental changes to it |
| Local variables with an initialiser | No | Inference is exact |
| Empty arrays, variables assigned later | Yes | The initial value carries no information |
| Object literals that must match a known type | Yes, or use satisfies | Enables checks for missing and extra properties |
Assertions and satisfies. Sometimes you know more than the compiler. A type assertion (value as Type) tells it to trust you. It does nothing at runtime, and TypeScript only allows it when the two types overlap. The non-null assertion (value!) removes null and undefined from a type. The satisfies operator checks a value against a type without replacing the inferred type with the annotated one.
const input = document.querySelector('#email') as HTMLInputElement; // "trust me, it exists and is an input"
const canvas = document.querySelector('canvas')!; // "trust me, it is not null"
const n = 'hello' as number;
// error TS2352: Conversion of type 'string' to type 'number' may be a mistake...
type Route = { path: string; auth: boolean };
// With an annotation, the variable's type becomes exactly Record<string, Route>:
const routesA: Record<string, Route> = {
home: { path: '/', auth: false },
admin: { path: '/admin', auth: true },
};
routesA.adminn; // no error: any string key is allowed, the typo goes unnoticed
// With satisfies, the value is checked, but the precise inferred type is kept:
const routesB = {
home: { path: '/', auth: false },
admin: { path: '/admin', auth: true },
} satisfies Record<string, Route>;
routesB.adminn; // error TS2551: Property 'adminn' does not exist on type ... Did you mean 'admin'?
routesB.admin.path; // string
| Form | Checks the value? | Resulting type | Risk |
|---|---|---|---|
const x: T = value | Yes | T (widened to the annotation) | None, but specific information is lost |
const x = value satisfies T | Yes | The inferred type of value | None |
const x = value as T | Only that the types overlap | T | You can be wrong, and nothing checks it at runtime |
value! | No | The type without null/undefined | A runtime TypeError if you are wrong |
Key Takeaways
- Annotate parameters, public return types and variables without a useful initialiser; let inference handle the rest.
- Contextual typing gives callbacks their parameter types from the place where they are passed.
letwidens literals to their base type,constkeeps them, andas constkeeps them deeply and makes themreadonly.asand!are unchecked promises to the compiler;satisfieschecks a value against a type and keeps the precise inferred type.
Practice
- (Beginner) Before hovering in your editor, predict the inferred type of:
let a = 1,const b = 1,const c = [1, 'x'],const d = { kind: 'circle', r: 2 },const e = { kind: 'circle' } as const. Then hover over each to check.- (Intermediate) Write a
themeobject withcolorsandspacingentries. Check it once with an annotationRecord<string, string | number>and once withsatisfies, and compare what you can do withtheme.colorsin each case.- (Interview) "Should you add type annotations everywhere in a TypeScript code base?" Hint: talk about where inference is exact, where it has nothing to work from, and what explicit return types give you at module boundaries.
Arrays, Tuples and Enums
JavaScript has one array type for every purpose; TypeScript distinguishes lists of same-typed elements (arrays), fixed-length records accessed by position (tuples), and adds enums for named sets of constants.
Arrays. Two equivalent spellings, plus read-only variants:
const scores: number[] = [90, 85, 77];
const names: Array<string> = ['Ada', 'Grace']; // generic syntax, same type as string[]
const currencies: readonly string[] = ['EUR', 'USD']; // also written ReadonlyArray<string>
scores.push(100); // OK
currencies.push('GBP'); // error TS2339: Property 'push' does not exist on type 'readonly string[]'.
function sum(values: readonly number[]): number { // accepts both mutable and readonly arrays
return values.reduce((a, b) => a + b, 0);
}
Indexing an array gives the element type even when the index is out of range: scores[10] is typed number although its value is undefined. The option noUncheckedIndexedAccess (9.3) changes the type to number | undefined.
Tuples. A tuple is an array with a fixed length where each position has its own type. It is useful for small groups of values where the position carries the meaning:
type Point = [number, number];
type HttpHeader = [name: string, value: string]; // labelled elements, documentation only
type Range = [start: number, end?: number]; // optional last element
type Row = [id: number, ...cells: string[]]; // rest element: one number, then any number of strings
const origin: Point = [0, 0];
const header: HttpHeader = ['Content-Type', 'application/json'];
const row: Row = [7, 'Ada', 'Lovelace', 'London'];
const [hName, hValue] = header; // destructuring keeps the per-position types
const entries = Object.entries({ a: 1, b: 2 }); // [string, number][]
// Returning several values, as React's useState does
function useToggle(initial: boolean): [boolean, () => void] {
let value = initial;
return [value, () => { value = !value; }];
}
const [isOpen, toggle] = useToggle(false);
const bad: Point = [1, 2, 3];
// error TS2322: Type '[number, number, number]' is not assignable to type 'Point'.
// Source has 3 element(s) but target allows only 2.
Tuples are still arrays at runtime, so origin.push(5) compiles. Use readonly [number, number] when the length must really stay fixed. as const on an array literal produces a readonly tuple of literal types:
const rgb = [255, 128, 0] as const; // readonly [255, 128, 0]
Enums. An enum gives names to a set of related constants. Unlike almost every other TypeScript feature, it is not just a type: it emits a JavaScript object.
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
enum Role {
Admin = 'ADMIN',
Editor = 'EDITOR',
Viewer = 'VIEWER',
}
function move(d: Direction): void {}
move(Direction.Left); // OK
const raw: number = 42;
move(raw); // OK, unfortunately: any number is accepted for a numeric enum
function setRole(r: Role): void {}
setRole(Role.Admin); // OK
setRole('ADMIN'); // error TS2345: Argument of type '"ADMIN"' is not assignable to parameter of type 'Role'.
The emitted JavaScript for Direction shows the runtime object, including a reverse mapping from numbers to names:
var Direction;
(function (Direction) {
Direction[Direction["Up"] = 0] = "Up"; // Direction.Up === 0 and Direction[0] === "Up"
Direction[Direction["Down"] = 1] = "Down";
Direction[Direction["Left"] = 2] = "Left";
Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));
Because enums generate code, they are not supported by tools that only strip types, such as Node.js's built-in TypeScript support (9.3), and the erasableSyntaxOnly option forbids them. Many teams therefore use a union of string literals or a constant object instead:
// Alternative 1: a union of literal types (no runtime value)
type Status = 'draft' | 'published' | 'archived';
// Alternative 2: an "as const" object (runtime values plus a derived type)
const Theme = {
Light: 'light',
Dark: 'dark',
} as const;
type Theme = (typeof Theme)[keyof typeof Theme]; // 'light' | 'dark'
function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
}
applyTheme(Theme.Dark); // OK
applyTheme('light'); // OK: plain literals are accepted too
Object.values(Theme); // ['light', 'dark']: easy to iterate, e.g. for a <select>
| Option | Runtime object | Accepts plain literals | Iterable | Works with type stripping |
|---|---|---|---|---|
Numeric enum | Yes, with reverse mapping | Any number | Awkward (names and numbers mixed) | No |
String enum | Yes | No | Yes | No |
const enum | No (values inlined) | No | No | No, and problematic with isolatedModules |
| Union of string literals | No | Yes | No | Yes |
as const object + derived type | Yes | Yes | Yes | Yes |
Key Takeaways
T[]andArray<T>are the same type; usereadonly T[]for arrays that must not be mutated, especially for parameters.- Tuples are fixed-length arrays with a type per position; they can have labels, optional and rest elements, and should be
readonlyif the length matters. - Enums are one of the few TypeScript features that emit runtime code; numeric enums accept any number, and string enums reject plain string literals.
- Unions of string literals and
as constobjects are the common, erasable alternatives to enums.
Practice
- (Beginner) Declare a list of product prices, a read-only list of supported language codes and an RGB color as a tuple. Try to
pushonto the read-only list and to assign a 4-element array to the RGB tuple.- (Intermediate) Convert
enum LogLevel { Debug, Info, Warn, Error }to anas constobject and a derivedLogLeveltype. WriteshouldLog(level: LogLevel, threshold: LogLevel): booleanthat keeps the original ordering (hint: an orderedreadonlyarray of levels andindexOf).- (Interview) "Why do some teams forbid
enumin their TypeScript code?" Hint: consider emitted code, type-stripping tools, the assignability of numeric enums and how string enums treat plain strings.
Union and Intersection Types
Real data is rarely "exactly one type". An ID may be a number or a string, a function may return a result or null, and an entity may combine several independent traits. Union types (A | B) and intersection types (A & B) let you express these combinations.
Types as sets of values. The clearest way to reason about unions and intersections is to treat every type as the set of values it allows:
string -> { "", "a", "hello", ... }
'GET' | 'POST' -> { 'GET', 'POST' }
string | number -> all strings plus all numbers union: values in A OR B
{ id: number } & { name: string }
-> objects having BOTH id and name intersection: values in A AND B
string & number -> { } which is never no value is both
Union types. A value of type A | B is either an A or a B, and you do not know which. You can therefore only use what is valid for every member of the union, until you check which one you have (narrowing, see the last topic of 9.1):
function formatId(id: string | number): string {
id.toUpperCase(); // error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
if (typeof id === 'string') {
return id.toUpperCase(); // id: string inside this branch
}
return id.toFixed(0); // id: number here, because the string case has returned
}
formatId(42); // OK
formatId('a-17'); // OK
formatId(true); // error TS2345: Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
The same rule applies to unions of object types: only properties common to all members are accessible.
type Cat = { name: string; meow(): void };
type Dog = { name: string; bark(): void };
function greet(pet: Cat | Dog): void {
console.log(pet.name); // OK: every member has name
pet.bark(); // error TS2339: Property 'bark' does not exist on type 'Cat | Dog'.
}
Unions with null and undefined are the most common of all, and the optional chaining and nullish coalescing operators (Chapter 3.2) are designed for them:
function cityOf(user: { address?: { city: string } } | null): string {
return user?.address?.city ?? 'unknown'; // safe on every path
}
Intersection types. A value of type A & B is both an A and a B, so it has every property of both. Intersections are used to compose types from smaller pieces:
type WithId = { id: string };
type Timestamped = { createdAt: Date; updatedAt: Date };
type Article = WithId & Timestamped & { title: string; body: string };
const post: Article = {
id: 'p1',
title: 'Hello',
body: '...',
createdAt: new Date(),
updatedAt: new Date(),
};
// Object.assign is typed with an intersection of its arguments
const settings = Object.assign({ theme: 'dark' }, { fontSize: 14 });
settings.fontSize; // number: settings is { theme: string } & { fontSize: number }
When the parts contradict each other, the intersection silently becomes impossible instead of reporting an error:
type A = { id: string };
type B = { id: number };
type C = A & B; // id: string & number, which is never
const c: C = { id: 1 }; // error TS2322: Type 'number' is not assignable to type 'never'.
Why unions seem to have "fewer" properties. The set view explains a common confusion. A union is a bigger set of values, so you know less about any given value and fewer properties are guaranteed. An intersection is a smaller set of values, so you know more about each value and more properties are guaranteed.
Union A | B | Intersection A & B | |
|---|---|---|
| Values allowed | Values of A, plus values of B | Values that are both A and B |
| Size of the set of values | Larger (or equal) | Smaller (or equal) |
| Properties you can use | Only those common to A and B | All properties of A and of B |
| Typical use | Alternatives: string | null, 'asc' | 'desc', result-or-error | Composition: mixins, adding fields to an existing type |
| Contradictory parts | Simply both alternatives | Collapse to never |
Key Takeaways
- Think of types as sets of values: a union is the set union, an intersection is the set intersection.
- With a union you can only use what all members share until you narrow the value to one member.
- An intersection has all the properties of its parts; conflicting parts collapse to
neverwithout a clear error. T | nullandT | undefinedare the standard way to model optional values understrictNullChecks.
Practice
- (Beginner) Write
padLeft(value: string, padding: string | number): string, which addspaddingspaces when it is a number and prepends it as text when it is a string.- (Intermediate) Define
HasEmail = { email: string }andHasPhone = { phone: string }. Writenotify(contact: HasEmail | HasPhone, message: string)that uses whichever channel is available, andnotifyBoth(contact: HasEmail & HasPhone, message: string). Note which property accesses the compiler rejects in each function and explain why.- (Interview) "Why does a union of two object types let you access fewer properties than either type alone, while an intersection lets you access more?" Hint: compare the set of values allowed with the set of properties that every one of those values is guaranteed to have.
Type Aliases vs Interfaces
Writing { id: number; email: string } everywhere would be repetitive and fragile. TypeScript offers two ways to give a type a name: type aliases and interfaces. They overlap heavily for object types, which is why choosing between them is a frequent question.
Type aliases. type Name = ... gives a name to any type expression: primitives, unions, tuples, functions, objects, and the computed types of 9.2.
type UserId = string;
type Status = 'active' | 'suspended';
type Coordinates = [lat: number, lng: number];
type Comparator<T> = (a: T, b: T) => number;
type User = {
readonly id: UserId; // cannot be reassigned after creation
email: string;
status: Status;
nickname?: string; // optional: may be missing
};
Interfaces. interface Name { ... } declares an object type: properties, methods, call or construct signatures, and index signatures.
interface Product {
readonly sku: string;
name: string;
price: number;
tags?: string[];
discount(percent: number): number; // method signature
}
interface PriceTable {
[currency: string]: number; // index signature: any string key maps to a number
}
const prices: PriceTable = { EUR: 10, USD: 11 };
Extending. Interfaces extend other interfaces; aliases combine with intersections. The two behave differently on conflicts:
interface Animal {
name: string;
}
interface Dog extends Animal { // Dog has name and breed
breed: string;
}
type AnimalT = { name: string };
type DogT = AnimalT & { breed: string }; // same shape, built with an intersection
interface BadDog extends Animal { // error TS2430: Interface 'BadDog' incorrectly extends interface 'Animal'.
name: number; // Types of property 'name' are incompatible.
}
type BadDogT = AnimalT & { name: number }; // no error here: name silently becomes never
Declaration merging. Two interface declarations with the same name in the same scope are merged into one. This is how you augment existing types, such as adding a property that a script tag places on window (9.2 covers declare global in modules):
export {}; // declare global is only allowed in a module (a file with an import or export)
declare global {
interface Window {
analytics: { track(event: string, data?: object): void }; // merged into the built-in Window
}
}
window.analytics.track('signup', { plan: 'pro' }); // OK
type Settings = { theme: string };
type Settings = { fontSize: number }; // error TS2300: Duplicate identifier 'Settings'.
Excess property checks. Structural typing normally allows extra properties (see the first topic). But when an object literal is assigned directly to a typed location, TypeScript treats extra properties as probable typos:
interface Options {
timeout?: number;
retries?: number;
}
const a: Options = { timeout: 1000, retrys: 3 };
// error TS2353: Object literal may only specify known properties, and 'retrys' does not exist in type 'Options'.
const raw = { timeout: 1000, retrys: 3 };
const b: Options = raw; // no error: not a fresh literal, so only the shape is compared
Comparison.
| Feature | interface | type |
|---|---|---|
| Object shapes | Yes | Yes |
| Unions, tuples, primitives, function types, mapped and conditional types | No | Yes |
| Extension | extends; conflicting properties are an error | &; conflicting properties become never |
| Declaration merging and augmentation | Yes | No (duplicate identifier) |
Used with implements in classes | Yes | Yes, if it is an object type |
| Name shown in hovers and errors | Always the interface name | Sometimes expanded into its structure |
| Checking performance in large projects | Slightly better (extends relations are cached) | Large intersections are recomputed |
Which one to use? Both are correct for object types, and consistency matters more than the choice. A common convention:
- Use
interfacefor object shapes, especially public APIs and library types that consumers may need to augment. - Use
typefor everything else: unions, tuples, function types, and types computed from other types. - Enforce the convention with the
@typescript-eslint/consistent-type-definitionslint rule.
Analogy: an interface is a contract form that can be amended by later addenda (declaration merging). A type alias is a nickname: a label you can stick on any type at all, but once written it cannot be changed.
Key Takeaways
- A type alias names any type; an interface declares an object type.
interface ... extendsreports conflicting properties, whereas an intersection silently turns them intonever.- Only interfaces support declaration merging, which is how built-in and library types are augmented.
- Fresh object literals get excess property checks, which catch typos in option names.
- Pick one convention (for example, interfaces for object shapes and aliases for everything else) and enforce it with a linter.
Practice
- (Beginner) Model a
BlogPostwith a read-onlyid, atitle, an optionalsubtitleand atagsarray, once as an interface and once as a type alias. Try reassigningidand omittingtitle.- (Intermediate) Create an interface
Animal { name: string }and aDogthat extends it while redeclaringname: number. Repeat with type aliases and&. Explain the different outcomes and which one you would prefer to find in a code review.- (Interview) "When would you choose an interface over a type alias, and when is a type alias the only option?" Hint: think about augmenting third-party types, unions and computed types.
Literal Types and Type Narrowing
Many values in a program come from a small, fixed set: an HTTP method, a button size, a log level. Literal types describe such sets exactly, and narrowing is how TypeScript follows your if statements to work out which specific type a value has at each point in the code.
Literal types. Any string, number or boolean literal can be used as a type that contains only that value. Combined in a union, literal types describe a finite set of options, and the editor autocompletes them:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function request(url: string, method: HttpMethod): void {}
request('/api/users', 'GET'); // OK
request('/api/users', 'get'); // error TS2345: Argument of type '"get"' is not assignable to parameter of type 'HttpMethod'.
Losing literal types through widening. As seen in the inference topic, let and object properties widen literals to their base type, which causes a classic error:
const req = { url: '/api/users', method: 'GET' }; // method: string
request(req.url, req.method);
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'HttpMethod'.
// Three fixes:
const req1: { url: string; method: HttpMethod } = { url: '/api/users', method: 'GET' }; // annotate
const req2 = { url: '/api/users', method: 'GET' } as const; // keep all literals
const req3 = { url: '/api/users', method: 'GET' as const }; // keep one literal
request(req2.url, req2.method); // OK
Template literal types build string types from other types, using the same syntax as template strings:
type CssLength = `${number}px` | `${number}rem` | `${number}%`;
type Side = 'top' | 'right' | 'bottom' | 'left';
type MarginProperty = `margin-${Side}`; // 'margin-top' | 'margin-right' | 'margin-bottom' | 'margin-left'
const width: CssLength = '12px'; // OK
const height: CssLength = '12'; // error TS2322: Type '"12"' is not assignable to type 'CssLength'.
Narrowing. When a value has a union type, TypeScript performs control flow analysis: it follows every branch, return, throw and assignment, and computes a more specific type for the variable at each point.
function describe(x: string | number | null)
|
if (x === null) -------------------> x: null -> return 'nothing'
|
v x: string | number
if (typeof x === 'string') --------> x: string -> return x.trim()
|
v x: number
return x.toFixed(2)
function describe(x: string | number | null): string {
if (x === null) return 'nothing';
if (typeof x === 'string') return x.trim();
return x.toFixed(2); // only number is left
}
Narrowing techniques.
| Technique | Example | What remains in the true branch |
|---|---|---|
typeof | typeof x === 'string' | One of string, number, bigint, boolean, symbol, undefined, object, function |
| Truthiness | if (x) | The type without null, undefined and falsy literals |
| Equality | x === null, x === 'GET' | The matching member(s) |
in | 'bark' in pet | Members that have that property |
instanceof | err instanceof TypeError | Instances of that class |
Array.isArray | Array.isArray(v) | Array members |
| Assignment | x = 'a' | The type of the assigned value, within the declared type |
| Type predicates, discriminants | isUser(v), switch (s.kind) | See 9.2 |
function area(shape: number | [number, number] | { w: number; h: number } | null): number {
if (!shape) return 0; // removes null (and the number 0)
if (typeof shape === 'number') return shape ** 2; // a square side
if (Array.isArray(shape)) return shape[0] * shape[1];
return shape.w * shape.h; // only the object is left
}
function handleError(err: unknown): string {
if (err instanceof Error) return err.message; // err: Error
if (typeof err === 'string') return err; // err: string
return 'Unknown error';
}
Pitfalls.
// 1. typeof null is "object" (Chapter 3.1), so this does not exclude null
function keys(value: object | null): string[] {
if (typeof value === 'object') {
return Object.keys(value);
// error TS2769: No overload matches this call... ('value' is possibly 'null')
}
return [];
}
// 2. Truthiness also removes valid falsy values
function label(count: number | undefined): string {
if (count) return `${count} items`; // 0 falls through to the "no data" branch
return 'no data';
}
// 3. Narrowing of a mutable property is not kept inside a callback,
// because the property might change before the callback runs
function schedule(config: { delay?: number }): void {
if (config.delay !== undefined) {
setTimeout(() => {
console.log(config.delay.toFixed(0));
// error TS18048: 'config.delay' is possibly 'undefined'.
}, 0);
const delay = config.delay; // fix: copy the narrowed value into a const
setTimeout(() => console.log(delay.toFixed(0)), 0);
}
}
Key Takeaways
- Literal types describe exact values; unions of literals model fixed sets of options with autocompletion.
- Literals widen in
letvariables and object properties; use an annotation oras constto keep them. - Template literal types build string patterns such as
`${number}px`. - Control flow analysis narrows union types through
typeof, truthiness, equality,in,instanceof,Array.isArrayand assignments. - Watch out for
typeof null === 'object', truthiness removing0and'', and narrowing that is lost inside callbacks.
Practice
- (Beginner) Define
type Size = 'sm' | 'md' | 'lg'andbuttonClass(size: Size): string. Call it with a value fromlet s = 'md'and fix the resulting error in two different ways.- (Intermediate) Write
formatValue(v: string | number | boolean | Date | null | string[]): stringand use at least five different narrowing techniques. Then addbigintto the union and confirm the compiler tells you where handling is missing (hint: declare an explicit return type).- (Interview) "How does TypeScript know that
xis astringinsideif (typeof x === 'string') { ... }, and why can that knowledge disappear inside a callback?" Hint: control flow analysis, and the fact that a callback may run later, after other code has changed the value.
<a id="92-advanced-typing"></a>
9.2 Advanced Typing
This subchapter covers the tools for writing reusable and precise types: generics and their constraints, function and constructor types, type guards and discriminated unions, the built-in utility types, type-level programming with mapped and conditional types, and declaration files that describe code TypeScript did not compile itself.
Generics and Type Parameters
Suppose you write a function that returns the first element of an array. It works for arrays of anything, but how do you type it? With any, the function accepts everything but forgets what it was given. Writing one version per element type does not scale. Generics solve this: a function (or type, or class) takes a type parameter, a placeholder that is filled with a concrete type each time it is used.
Analogy: a function's ordinary parameters are placeholders for values; type parameters are placeholders for types. A generic Box<T> is like a shipping container with a blank label: the container works the same for every cargo, and the label is filled in when it is loaded, so whoever unloads it knows what is inside.
function firstAny(items: any[]): any {
return items[0];
}
const a = firstAny(['x', 'y']); // any: the information "string" is lost
function first<T>(items: T[]): T | undefined {
return items[0];
}
const s = first(['x', 'y']); // string | undefined (T inferred as string)
const d = first([new Date()]); // Date | undefined
const n = first<number>([1, 2]); // explicit type argument, rarely needed
The key property is that the type parameter connects the input and the output: whatever element type goes in comes back out.
How type arguments are inferred. At each call, TypeScript infers type parameters from the arguments, including from what a callback returns:
function map<T, U>(items: readonly T[], fn: (item: T, index: number) => U): U[] {
const result: U[] = [];
items.forEach((item, i) => result.push(fn(item, i)));
return result;
}
const lengths = map(['a', 'bb', 'ccc'], (s) => s.length); // number[]
map(['a', 'bb', 'ccc'], (s) => s.length)
| | |
T = string s: string U = number (U comes from the callback's return type)
result type: U[] = number[]
Generic types, interfaces and classes. Types can be parameterised too, and many built-in types already are: Array<T>, Promise<T>, Map<K, V>, Set<T>, Record<K, V>.
interface ApiResponse<T> {
data: T;
status: number;
receivedAt: Date;
}
type Pair<A, B> = [first: A, second: B];
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
get size(): number {
return this.items.length;
}
}
const history = new Stack<string>(); // explicit: there is no constructor argument to infer from
history.push('/home');
history.push(42); // error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
const userResponse: ApiResponse<{ id: number; name: string }> = {
data: { id: 1, name: 'Ada' },
status: 200,
receivedAt: new Date(),
};
const entry: Pair<string, number> = ['retries', 3];
const cache = new Map<string, Promise<Response>>(); // nested generics
Generics and asynchronous code. An async function always returns Promise<T>, and generic helpers are a common way to type HTTP calls. Be aware of what such a helper does and does not guarantee:
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T; // T is a promise you make, not something TypeScript verifies
}
interface User {
id: number;
name: string;
}
const me = await getJson<User>('/api/me'); // me: User, but only as trustworthy as the server
The rule of thumb for type parameters. A type parameter should relate at least two things, usually appearing in two places (two parameters, or a parameter and the return type). A type parameter that appears only once is a disguised any or a disguised type assertion:
// Bad: T appears only in the return type; callers can pick any T and nothing checks it
function parse<T>(json: string): T {
return JSON.parse(json);
}
const port = parse<number>('"not a number"'); // compiles; port is actually a string
// Better: return unknown and validate (type guards, later in 9.2)
function parseSafe(json: string): unknown {
return JSON.parse(json);
}
One syntax detail: in .tsx files, <T> before an arrow function is read as a JSX tag. Write <T,>(value: T) => value or use a constraint (next topic).
Key Takeaways
- Generics add type parameters to functions, interfaces, type aliases and classes, so one definition works for many types while keeping track of which one is in use.
- Type arguments are usually inferred from the call's arguments; specify them explicitly only when there is nothing to infer from.
- A good type parameter links inputs to outputs; one that appears only once is a disguised
any. - Generic HTTP helpers such as
getJson<T>are only assertions: the response is not checked at runtime.
Practice
- (Beginner) Write
last<T>(items: readonly T[]): T | undefinedandwrap<T>(value: T): T[]. Hover over the results of a few calls to see whatTwas inferred as.- (Intermediate) Implement
groupBy<T>(items: readonly T[], key: (item: T) => string): Record<string, T[]>and use it to group a list of orders by customer. Then write a genericQueue<T>class withenqueue,dequeue,peekandsize.- (Interview) "What is wrong with the signature
function parse<T>(json: string): T?" Hint: count how many timesTappears and ask who checks that the parsed value really is aT.
Constraints and Default Type Arguments
Inside a generic function, an unconstrained T could be anything, so TypeScript lets you do almost nothing with it: you cannot read .length or call a method. A constraint (T extends Something) limits which types callers may supply, and in exchange lets the function use the members of Something. A default type argument (T = Something) says which type to use when none is given or inferred.
Constraints with extends. T extends X means "T must be assignable to X". Inside the function, T can be used as an X; outside, the caller still gets back their specific type:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b; // OK: every T has a length
}
longest('abc', 'de'); // string
longest([1, 2], [1, 2, 3]); // number[]: the specific type is preserved, not { length: number }
longest(10, 20); // error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
keyof constraints. keyof T is the union of the property names of T, and T[K] is the type of property K in T (both are covered in detail later in 9.2). Together with a constraint they produce a type-safe property getter:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Ada', admin: false };
getProperty(user, 'name'); // string
getProperty(user, 'admin'); // boolean
getProperty(user, 'email'); // error TS2345: Argument of type '"email"' is not assignable to parameter of type '"admin" | "id" | "name"'.
A constraint is a lower bound, not the type itself. The caller chooses T, and it may be a subtype with more properties than the constraint. You therefore cannot return "some value that matches the constraint" where a T is expected:
function withMinLength<T extends { length: number }>(value: T, min: number): T {
if (value.length >= min) return value;
return { length: min };
// error TS2322: Type '{ length: number; }' is not assignable to type 'T'.
// '{ length: number; }' is assignable to the constraint of type 'T', but 'T' could be
// instantiated with a different subtype of constraint '{ length: number; }'.
}
If the caller passed a string, the function would return a plain object and break the promise that the result is a string.
Constraints that relate type parameters. One type parameter can be constrained by another, and object constraints are common for merge-style helpers:
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
const merged = merge({ host: 'localhost' }, { port: 8080 }); // { host: string } & { port: number }
function copyFields<T extends U, U extends object>(target: T, source: U): T {
return Object.assign(target, source); // source may only contain fields that target also has
}
Default type arguments. A type parameter can have a default, used when the type is written without arguments or when nothing can be inferred:
interface ApiError {
code: string;
message: string;
}
type Result<T, E = ApiError> =
| { ok: true; value: T }
| { ok: false; error: E };
const r1: Result<number> = { ok: true, value: 42 }; // E defaults to ApiError
const r2: Result<number, string> = { ok: false, error: 'timeout' }; // E given explicitly
function createStore<S = Record<string, unknown>>(initial?: S) {
let state = initial ?? ({} as S);
return {
get: (): S => state,
set: (next: S): void => { state = next; },
};
}
createStore(); // S = Record<string, unknown>: nothing to infer, the default applies
createStore({ count: 0 }); // S = { count: number }: inference wins over the default
Rules for defaults: parameters with defaults must come after those without, a default must satisfy the parameter's constraint (<T extends object = {}>), and inferred types take precedence over defaults.
const type parameters. Generic inference normally widens literals. Adding the const modifier to a type parameter infers the narrowest type, as if the caller had written as const:
function defineRoutes<const T extends readonly string[]>(paths: T): T {
return paths;
}
const routes = defineRoutes(['/', '/about', '/blog']);
// readonly ['/', '/about', '/blog'] instead of string[]
type RoutePath = (typeof routes)[number]; // '/' | '/about' | '/blog'
Blocking inference with NoInfer. Sometimes a parameter should be checked against T but not contribute to inferring it:
function createMachine<S extends string>(states: S[], initial: NoInfer<S>): S {
return initial;
}
createMachine(['idle', 'loading', 'done'], 'idle'); // OK
createMachine(['idle', 'loading', 'done'], 'idel');
// error TS2345: Argument of type '"idel"' is not assignable to parameter of type '"done" | "idle" | "loading"'.
// Without NoInfer, S would be inferred as 'idle' | 'loading' | 'done' | 'idel' and the typo accepted.
| Pattern | Meaning | Example use |
|---|---|---|
<T extends string> | Only string types (keeps literals) | Event names, route paths |
<T extends object> | Only non-primitive types | Merging, cloning |
<K extends keyof T> | One of T's property names | Getters, setters, pluck, sortBy |
<F extends (...args: any[]) => unknown> | Any function | Wrappers such as debounce, memoize |
<T = Default> | Fallback when nothing is given or inferred | Library types such as ApiResponse<T = unknown> |
<const T> | Infer literal and tuple types | Configuration objects, route tables |
NoInfer<T> | Check against T without inferring from here | Default values, initial states |
Key Takeaways
T extends Xrestricts what callers may pass and lets the implementation useX's members, while callers keep their specific type.K extends keyof TwithT[K]produces type-safe access to properties by name.- A constraint is a lower bound: you cannot return an arbitrary value of the constraint type where
Tis expected. - Default type arguments apply when a type is written without arguments or nothing can be inferred; inference wins over defaults.
consttype parameters keep literal types, andNoInferstops a parameter from influencing inference.
Practice
- (Beginner) Write
pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][]and use it to extract the- (Intermediate) Using the
Result<T, E = ApiError>type above, writeparseJson(text: string): Result<unknown, SyntaxError>andsafeDivide(a: number, b: number): Result<number>(relying on the default forE). Write a caller that handles both cases without type assertions.- (Interview) "Why does TypeScript reject returning
{ length: min }from a function declared as<T extends { length: number }>(value: T, min: number) => T?" Hint: who choosesT, and what couldTbe besides{ length: number }?
Function and Constructor Types
Functions are values in JavaScript (Chapter 3.3): they are passed as callbacks, stored in objects and returned from other functions. TypeScript therefore needs a way to write the type of a function, and also the type of a class used as a value.
Function type expressions. The most common form looks like an arrow function with types instead of a body:
type BinaryOp = (a: number, b: number) => number;
type Predicate<T> = (value: T) => boolean;
const add: BinaryOp = (a, b) => a + b; // a and b are contextually typed as number
const isEven: Predicate<number> = (n) => n % 2 === 0;
function applyAll(values: number[], op: BinaryOp, initial: number): number {
return values.reduce(op, initial);
}
Parameter names in a function type are documentation only. A function with fewer parameters is assignable to a type with more, because JavaScript ignores extra arguments: that is why [1, 2, 3].forEach((n) => ...) is valid although forEach passes three arguments.
Call signatures and method signatures. When a function also has properties, describe it with an object type containing a call signature. Methods inside object types can be written in method or property syntax:
interface Formatter {
(value: number): string; // call signature: the object can be called
locale: string; // and it also has properties
}
const euro = ((value: number) => `${value.toFixed(2)} EUR`) as Formatter;
euro.locale = 'it-IT';
euro(9.5); // "9.50 EUR"
interface Repository {
findById(id: string): Promise<User | null>; // method syntax
save: (user: User) => Promise<void>; // property syntax
}
interface User {
id: string;
}
Optional, default, rest and this parameters.
function buildUrl(path: string, query?: Record<string, string>): string { // optional
return query ? `${path}?${new URLSearchParams(query)}` : path;
}
function sumAll(...values: number[]): number { // rest parameter: always an array type
return values.reduce((a, b) => a + b, 0);
}
function onClick(this: HTMLButtonElement, event: MouseEvent): void {
this.disabled = true; // `this` is typed by a fake first parameter, erased in the output
}
document.querySelector('button')?.addEventListener('click', onClick);
Overloads. When the return type depends on which arguments are passed, declare several overload signatures followed by one implementation. Callers only see the overloads; the implementation signature must be compatible with all of them:
function parseDate(iso: string): Date;
function parseDate(timestamp: number): Date;
function parseDate(year: number, month: number, day: number): Date;
function parseDate(a: string | number, month?: number, day?: number): Date { // implementation: not callable directly
if (typeof a === 'string') return new Date(a);
if (month !== undefined && day !== undefined) return new Date(a, month - 1, day);
return new Date(a);
}
parseDate('2026-10-03'); // OK
parseDate(2026, 10, 3); // OK
parseDate(2026, 10);
// error TS2575: No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.
Prefer a union parameter when the return type is the same in all cases; overloads are for "different inputs, different outputs", such as document.createElement('img') returning an HTMLImageElement.
Parameter variance. A function that handles only dogs cannot stand in for a function that must handle any animal, because someone might call it with a cat. With strictFunctionTypes (part of strict), TypeScript enforces this for function-typed properties:
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
type AnimalHandler = (animal: Animal) => void;
const handleDog = (dog: Dog): void => dog.bark();
const handler: AnimalHandler = handleDog;
// error TS2322: Type '(dog: Dog) => void' is not assignable to type 'AnimalHandler'.
// Types of parameters 'dog' and 'animal' are incompatible.
Parameters declared with method syntax (handle(animal: Animal): void) are checked more loosely (bivariantly) for compatibility with existing JavaScript patterns, which is one reason some style guides prefer property syntax for callbacks.
Constructor types. A class name is two things at once: a type describing its instances, and a value, the constructor function. To accept a class as an argument, use a construct signature (new (...) => T):
class Logger {
log(msg: string): void {
console.log(msg);
}
}
function create<T>(ctor: new () => T): T {
return new ctor();
}
const logger = create(Logger); // Logger
let instance: Logger = new Logger(); // "Logger" as a type: the instance side
let klass: typeof Logger = Logger; // "typeof Logger": the constructor (static) side
type FromCtor = InstanceType<typeof Logger>; // Logger again
| Written as | Means | Example value |
|---|---|---|
Logger | The type of instances | new Logger() |
typeof Logger | The type of the class itself (constructor plus static members) | Logger |
new () => Logger | Anything constructible without arguments that produces a Logger | Logger, or a subclass |
abstract new () => Logger | The same, but abstract classes are also accepted | An abstract base class |
Construct signatures enable mixins, functions that take a class and return an extended class:
type Constructor<T = {}> = new (...args: any[]) => T;
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
createdAt = new Date();
};
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
const TimestampedPerson = Timestamped(Person);
const p = new TimestampedPerson('Ada');
console.log(p.name, p.createdAt.toISOString()); // both members are typed
Key Takeaways
- Function types are written
(param: Type) => Return; use a call signature in an object type when the function also has properties. - Functions with fewer parameters are assignable to function types with more parameters.
- Use overloads only when the return type depends on the arguments; otherwise use a union parameter.
- Under
strictFunctionTypes, function-typed properties check parameters contravariantly; method syntax is checked more loosely. - A class name used as a type means its instances;
typeof ClassNameandnew (...) => Tdescribe the constructor.
Practice
- (Beginner) Define
type Predicate<T> = (value: T) => booleanand writefilterBy<T>(items: readonly T[], ...predicates: Predicate<T>[]): T[], which keeps items that pass every predicate.- (Intermediate) Write overloads for
createElement(tag: 'a'): HTMLAnchorElement,createElement(tag: 'img'): HTMLImageElementandcreateElement(tag: string): HTMLElement. Then replace them with a single generic signature usingK extends keyof HTMLElementTagNameMapand compare the two versions.- (Interview) "What is the difference between using
Loggerandtypeof Loggeras a type annotation?" Hint: a class declaration creates both an instance type and a constructor value with its own static members.
Type Guards and Discriminated Unions
The narrowing techniques of 9.1 work with JavaScript operators that the compiler understands, such as typeof and instanceof. Real programs also need custom checks ("is this object a valid User?") and need to model data that can be in one of several well-defined states. Type guards teach the compiler about custom checks, and discriminated unions model alternatives so that narrowing becomes automatic.
User-defined type guards. A function whose return type is a type predicate, value is Type, tells the compiler that when the function returns true, the argument has that type:
interface User {
id: number;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === 'object' && value !== null &&
'id' in value && typeof value.id === 'number' &&
'email' in value && typeof value.email === 'string'
);
}
const data: unknown = await (await fetch('/api/me')).json();
if (isUser(data)) {
console.log(data.email); // data: User
} else {
console.error('Unexpected response', data);
}
The compiler trusts the predicate completely: if the function body checks the wrong things, the "guarantee" is wrong. Keep guards small and test them, or generate them from a schema library (9.3).
Assertion functions. Instead of returning a boolean, an assertion function throws when the check fails, and narrows the type for all code after the call:
function assertDefined<T>(value: T, name: string): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`${name} is missing`);
}
}
const root = document.getElementById('app'); // HTMLElement | null
assertDefined(root, '#app');
root.append('Loaded'); // root: HTMLElement from here on
Inferred type predicates. Since TypeScript 5.5, simple arrow functions like x => x !== null are recognised as type guards automatically, which fixes a long-standing annoyance with filter:
const maybeNumbers = [1, null, 3, undefined, 5];
const numbers = maybeNumbers.filter((x) => x != null); // number[] (previously (number | null | undefined)[])
Discriminated unions. A discriminated (or tagged) union is a union of object types that share one property, the discriminant, whose type is a different literal in each member. Checking that property narrows the whole object:
RemoteData<T>
status: 'idle' | 'loading' | 'success' | 'error' <- discriminant
| | | |
{ } { } { data: T } { error: Error } <- member-specific fields
switch (state.status) selects exactly one member, so only its fields are accessible
type RemoteData<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function render(state: RemoteData<User[]>): string {
switch (state.status) {
case 'idle':
return 'Press "Load" to fetch users';
case 'loading':
return 'Loading...';
case 'success':
return `${state.data.length} users`; // data exists only in this member
case 'error':
return `Failed: ${state.error.message}`; // error exists only in this member
default:
return assertNever(state); // state: never if every case is handled
}
}
Exhaustiveness checking. The default branch receives whatever has not been handled. If every member is handled, that is never, and the call compiles. If someone later adds { status: 'cancelled' } to the union, the compiler points to every switch that forgot it:
error TS2345: Argument of type '{ status: "cancelled"; }' is not assignable to parameter of type 'never'.
Making illegal states unrepresentable. Compare a discriminated union with the common "bag of flags" design:
// Flags: 2 x 2 x 2 = 8 combinations, most of them meaningless
interface LoadStateFlags {
isLoading: boolean;
data?: User[];
error?: Error;
}
const nonsense: LoadStateFlags = { isLoading: true, data: [], error: new Error('?') }; // compiles
// Discriminated union: exactly the 4 valid states, and nothing else compiles
const invalid: RemoteData<User[]> = { status: 'loading', data: [] };
// error TS2353: Object literal may only specify known properties, and 'data' does not exist in type '{ status: "loading"; }'.
The same pattern models Redux-style actions, WebSocket messages, form steps and parse results. Requirements: the discriminant must be a property with literal types (strings, numbers, booleans, null, undefined) present in every member, and you narrow with ===, !== or switch.
| Guard kind | Syntax | Narrows | Checked by the compiler? |
|---|---|---|---|
| Built-in operators | typeof, instanceof, in, === | Automatically | Yes |
| Type predicate | (v: unknown): v is T | In the true branch (and false branch) | No: you write the check |
| Inferred predicate | (x) => x !== null | Automatically | Yes |
| Assertion function | asserts v is T | After the call | No: you write the check |
| Discriminant check | switch (v.kind) | Automatically | Yes |
Key Takeaways
- A type predicate (
value is T) turns a boolean function into a type guard; the compiler trusts it, so it must be correct. - Assertion functions (
asserts value is T) throw on failure and narrow the type for the code that follows. - Discriminated unions use a shared literal property to tell members apart; checking it narrows the whole object.
- A
never-typed default branch gives exhaustiveness checking: adding a new member breaks the build wherever it is not handled. - Prefer discriminated unions over combinations of boolean flags to make invalid states impossible to construct.
Practice
- (Beginner) Write
isString(value: unknown): value is stringand use it withfilteron anunknown[]to obtain astring[].- (Intermediate) Model
Shapeas a discriminated union ofcircle,rectangleandtrianglewith akindfield. Writearea(shape: Shape): numberwith an exhaustiveswitch. Addsquareto the union and fix every error the compiler reports.- (Interview) "How would you type the state of a checkout form so that it can never show a success message and an error message at the same time?" Hint: list the valid states, give each a literal tag and only the fields that make sense in it.
Utility Types
Applications describe the same entity in several variations: the full User stored in the database, the subset a client sends to create one, the partial update a PATCH request carries, and the public version without the password hash. Writing each variation by hand duplicates fields that then drift apart. Utility types are built-in generic types that derive new types from existing ones, so there is a single source of truth.
Analogy: a database table and its views. The User interface is the table; Pick, Omit and Partial are views over it. Add a column to the table and every view that should include it updates automatically.
Deriving types from a model.
interface User {
id: string;
email: string;
name: string;
passwordHash: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: Date;
}
type NewUser = Omit<User, 'id' | 'createdAt'>; // what a client sends to create a user
type UserPatch = Partial<Pick<User, 'email' | 'name'>>; // the fields a PATCH request may change
type PublicUser = Omit<User, 'passwordHash'>; // safe to send to the browser
type FrozenUser = Readonly<User>; // no property can be reassigned
type Permissions = Record<User['role'], string[]>; // one entry per role, none forgotten
const permissions: Permissions = {
admin: ['*'],
editor: ['posts:write'],
};
// error TS2741: Property 'viewer' is missing in type '{ admin: string[]; editor: string[]; }'
// but required in type 'Permissions'.
function updateUser(user: User, patch: UserPatch): User {
return { ...user, ...patch };
}
updateUser({} as User, { role: 'admin' });
// error TS2353: Object literal may only specify known properties, and 'role' does not exist in type 'Partial<Pick<User, "email" | "name">>'.
Working with unions.
type Role = User['role']; // 'admin' | 'editor' | 'viewer'
type StaffRole = Exclude<Role, 'viewer'>; // 'admin' | 'editor'
type DomEvent = 'click' | 'keydown' | 'keyup' | 'focus';
type KeyEvent = Extract<DomEvent, `key${string}`>; // 'keydown' | 'keyup'
type MaybeName = string | null | undefined;
type Name = NonNullable<MaybeName>; // string
Deriving types from functions and classes. The type query typeof turns a value (such as a function) into its type, and utility types pick it apart. This keeps types in sync with code that already exists:
async function fetchUser(id: string, opts?: { signal?: AbortSignal }) {
const res = await fetch(`/api/users/${id}`, opts);
return (await res.json()) as PublicUser;
}
type FetchUserArgs = Parameters<typeof fetchUser>; // [id: string, opts?: { signal?: AbortSignal }]
type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>; // PublicUser
class HttpClient {
constructor(readonly baseUrl: string, readonly timeoutMs = 5000) {}
}
type ClientArgs = ConstructorParameters<typeof HttpClient>; // [baseUrl: string, timeoutMs?: number]
type Client = InstanceType<typeof HttpClient>; // HttpClient
Reference table.
| Utility type | Result | Example |
|---|---|---|
Partial<T> | All properties optional | Update payloads, options objects |
Required<T> | All properties required | Options after defaults are applied |
Readonly<T> | All properties readonly | Immutable state |
Pick<T, K> | Only the keys K | Summary or projection types |
Omit<T, K> | All keys except K | Removing secrets or server-generated fields |
Record<K, V> | An object with keys K and values V | Lookup tables, exhaustive maps |
Exclude<U, X> | Members of union U not assignable to X | Removing options |
Extract<U, X> | Members of union U assignable to X | Selecting options |
NonNullable<T> | T without null and undefined | After validation |
ReturnType<F> | Return type of function type F | Types of factory results |
Parameters<F> | Parameter types of F as a tuple | Wrappers that forward arguments |
ConstructorParameters<C> | Constructor parameter types as a tuple | Factories |
InstanceType<C> | Instance type of a constructor type | Dependency injection containers |
Awaited<T> | The value a promise resolves to (recursively) | Results of async functions |
NoInfer<T> | T, excluded from inference | See constraints and defaults |
Uppercase, Lowercase, Capitalize, Uncapitalize | Transformed string literal types | Event and method names |
Caveats.
// 1. Readonly and Partial are shallow: nested objects are unaffected
type Settings = { theme: { color: string } };
const s: Readonly<Settings> = { theme: { color: 'red' } };
s.theme = { color: 'blue' }; // error TS2540: Cannot assign to 'theme' because it is a read-only property.
s.theme.color = 'blue'; // allowed: theme's own properties are still mutable
// 2. Omit does not check that the keys exist; Pick does
type NoSecret = Omit<User, 'passwrodHash'>; // typo, no error, and passwordHash is still included
type OnlyName = Pick<User, 'nmae'>;
// error TS2344: Type '"nmae"' does not satisfy the constraint 'keyof User'.
When a derived type grows complicated, give its intermediate steps names: type UserPatch = Partial<Pick<User, EditableField>> with type EditableField = 'email' | 'name' reads better than one long expression.
Key Takeaways
- Utility types derive variations of a type from a single source of truth instead of duplicating fields.
Partial,Required,Readonly,Pick,OmitandRecordtransform object types;Exclude,ExtractandNonNullablefilter unions.ReturnType,Parameters,Awaited,InstanceTypeandConstructorParametersextract types from existing functions and classes viatypeof.ReadonlyandPartialare shallow, andOmitdoes not report misspelled keys.
Practice
- (Beginner) Given
interface Product { id: number; name: string; price: number; description: string; stock: number }, deriveProductDraft(noid),ProductSummary(id,name,price) andProductUpdate(idrequired, everything else optional).- (Intermediate) Write
toPublic(user: User): PublicUserthat removespasswordHashwith object rest destructuring. Then write aRecord<Role, (user: PublicUser) => boolean>of access checks and confirm the compiler complains when a role is missing.- (Interview) "Why does
Omit<User, 'pasword'>compile without an error whilePick<User, 'pasword'>does not, and how would you write a stricterOmit?" Hint: compare the constraints on the key parameter of each type (keyof anyvskeyof T).
Mapped and Conditional Types
Utility types are not magic: they are ordinary generic types written in TypeScript's own type language, using mapped types (loops over keys) and conditional types (if-then-else on types). Learning these tools lets you read library type definitions and write your own transformations.
The building blocks. Three type operators turn values and object types into other types:
| Operator | Meaning | Example |
|---|---|---|
typeof value | The type of a value (in a type position) | typeof config |
keyof T | Union of the property names of T | keyof User is 'id' | 'email' | ... |
T[K] | Type of property K in T (indexed access) | User['role'] |
T[number] | Element type of an array or tuple type | (typeof roles)[number] |
const config = { apiUrl: 'https://api.example.com', retries: 3, debug: false };
type Config = typeof config; // { apiUrl: string; retries: number; debug: boolean }
type ConfigKey = keyof Config; // 'apiUrl' | 'retries' | 'debug'
type Retries = Config['retries']; // number
type ConfigValue = Config[ConfigKey]; // string | number | boolean
const roles = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof roles)[number]; // 'admin' | 'editor' | 'viewer': the array is the single source of truth
Mapped types. A mapped type builds an object type by iterating over a union of keys, much like Array.prototype.map builds an array by iterating over elements:
type MyPartial<T> = { [K in keyof T]?: T[K] }; // for each key K of T: optional, same type
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] }; // "-" removes a modifier
type Concrete<T> = { [K in keyof T]-?: T[K] }; // removes "?": the same as Required<T>
type Flags<T> = { [K in keyof T]: boolean }; // same keys, different value type
type ConfigFlags = Flags<Config>; // { apiUrl: boolean; retries: boolean; debug: boolean }
Key remapping with as changes the key names, usually with a template literal type. Mapping a key to never removes it:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type ConfigGetters = Getters<Config>;
// { getApiUrl: () => string; getRetries: () => number; getDebug: () => boolean }
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K]; // keep a key only if its value type matches
};
type NumericConfig = PickByValue<Config, number>; // { retries: number }
Getters<Config>, evaluated key by key:
K new key `get${Capitalize<K>}` new value () => Config[K]
'apiUrl' 'getApiUrl' () => string
'retries' 'getRetries' () => number
'debug' 'getDebug' () => boolean
Conditional types. T extends U ? X : Y is a ternary operator on types: if T is assignable to U, the result is X, otherwise Y.
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
infer declares a type variable inside the extends clause and captures part of the matched type, like a capture group in a regular expression:
type ElementType<T> = T extends readonly (infer E)[] ? E : never;
type E1 = ElementType<string[]>; // string
type MyReturnType<F> = F extends (...args: any[]) => infer R ? R : never;
type R1 = MyReturnType<() => Promise<number>>; // Promise<number>
type UnwrapPromise<T> = T extends Promise<infer V> ? UnwrapPromise<V> : T; // recursive
type V1 = UnwrapPromise<Promise<Promise<string>>>; // string
Distribution over unions. When the checked type is a bare type parameter and receives a union, the conditional type is applied to each member separately and the results are joined. This is how Exclude works. Wrapping both sides in a tuple turns distribution off:
type MyExclude<T, U> = T extends U ? never : T;
type Ex = MyExclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c' ('a' -> never, 'b' -> 'b', 'c' -> 'c')
type ToArray<T> = T extends unknown ? T[] : never;
type D1 = ToArray<string | number>; // string[] | number[] (distributed)
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;
type D2 = ToArrayAll<string | number>; // (string | number)[] (not distributed)
Recursive types apply a transformation at every level, fixing the shallowness of the built-in Readonly:
type DeepReadonly<T> = T extends (...args: any[]) => unknown
? T // leave functions alone
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> } // recurse into objects and arrays
: T; // primitives stay as they are
type AppState = { user: { name: string; tags: string[] } };
const state: DeepReadonly<AppState> = { user: { name: 'Ada', tags: ['admin'] } };
state.user.name = 'Grace'; // error TS2540: Cannot assign to 'name' because it is a read-only property.
state.user.tags.push('x'); // error TS2339: Property 'push' does not exist on type 'readonly string[]'.
A practical example: a typed event emitter. A mapped type over an event map links each event name to its payload type, so subscribers and publishers cannot disagree:
type AppEvents = {
login: { userId: string };
logout: undefined;
error: Error;
};
class Emitter<Events extends Record<string, unknown>> {
private listeners: { [K in keyof Events]?: Array<(payload: Events[K]) => void> } = {};
on<K extends keyof Events>(event: K, listener: (payload: Events[K]) => void): void {
const list = this.listeners[event] ?? [];
list.push(listener);
this.listeners[event] = list;
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
}
const bus = new Emitter<AppEvents>();
bus.on('login', (p) => console.log(p.userId)); // p: { userId: string }
bus.emit('login', { userId: 'u1' }); // OK
bus.emit('login', { id: 'u1' });
// error TS2353: Object literal may only specify known properties, and 'id' does not exist in type '{ userId: string; }'.
bus.emit('signup', undefined);
// error TS2345: Argument of type '"signup"' is not assignable to parameter of type 'keyof AppEvents'.
AppEvents is declared with type, not interface, on purpose: interfaces do not satisfy the Record<string, unknown> constraint, because they may be augmented later with keys of any type.
Type-level programming is powerful but costly to read and to compile. Prefer the built-in utility types, give complex types descriptive names, and test them with a few example aliases or expectTypeOf assertions (from Vitest) the way you would test functions.
Key Takeaways
typeof,keyofand indexed access (T[K],T[number]) derive types from values and from other types.- Mapped types iterate over keys (
[K in keyof T]), can add or removereadonlyand?, and can rename or drop keys withas. - Conditional types (
T extends U ? X : Y) choose between types;infercaptures part of a matched type. - Conditional types distribute over unions when checking a bare type parameter;
[T] extends [U]prevents it. - Recursive mapped types such as
DeepReadonlyapply a transformation at every level of nesting.
Practice
- (Beginner) Write your own
MyPick<T, K extends keyof T>andMyRecord<K extends PropertyKey, V>with mapped types, and check them against the built-in versions on aUsertype.- (Intermediate) Write
Setters<T>, which turns{ name: string; age: number }into{ setName(value: string): void; setAge(value: number): void }, andFunctionKeys<T>, the union of keys whose values are functions.- (Interview) "Explain what makes a conditional type distributive, and give an example where distribution produces a surprising result." Hint: compare
T extends unknown ? T[] : neverwith[T] extends [unknown] ? T[] : neverforT = string | number, and consider what happens withT = never.
Declaration Files and Ambient Types
Most of the JavaScript ecosystem was not written in TypeScript, and even TypeScript libraries are published as compiled JavaScript. To type-check code that uses them, the compiler needs a description of their types without their implementation. That description is a declaration file (.d.ts), and the types it introduces are called ambient, because they describe things that exist somewhere else at runtime.
Analogy: a declaration file is like the specification sheet of an electronic component. An engineer designing a circuit needs the pin layout and voltage ratings, not the chip's internal design. Likewise, the compiler needs the names and types of a library's exports, not their code.
What a .d.ts file contains. Only types and declare statements, never executable code:
// math-utils.d.ts: describes math-utils.js, which contains the actual implementation
export declare function clamp(value: number, min: number, max: number): number;
export declare const PRECISION: number;
export interface Range {
min: number;
max: number;
}
export declare class Vector2 {
constructor(x: number, y: number);
readonly x: number;
readonly y: number;
add(other: Vector2): Vector2;
}
You rarely write such files for your own code: with "declaration": true the compiler generates them next to the emitted JavaScript.
src/math-utils.ts --tsc--> dist/math-utils.js (implementation, runs)
dist/math-utils.d.ts (types only, read by the compiler of consumers)
dist/math-utils.d.ts.map (optional: "go to definition" jumps to the .ts source)
The declare keyword. declare says "this exists at runtime; trust me, here is its type". It never emits code:
declare const __APP_VERSION__: string; // injected by the bundler at build time
declare function gtag(command: 'event', name: string, params?: object): void; // from a <script> tag
console.log(`Version ${__APP_VERSION__}`);
gtag('event', 'page_view');
Where the types of a package come from. When you write import x from 'some-lib', TypeScript looks for types in this order:
import { format } from 'some-lib'
|
v
1. Types bundled with the package package.json "exports" -> "types" condition,
or the "types"/"typings" field, or index.d.ts
| not found
v
2. DefinitelyTyped node_modules/@types/some-lib/index.d.ts
(community-maintained, installed with npm i -D @types/some-lib)
| not found
v
3. Your own declaration declare module 'some-lib' { ... } in a .d.ts in your project
| not found
v
error TS7016: Could not find a declaration file for module 'some-lib'.
Ambient module declarations. For an untyped package, or for non-JavaScript imports handled by a bundler, declare the module yourself in a .d.ts file that is part of the project (for example src/types/modules.d.ts):
// src/types/modules.d.ts
declare module 'tiny-slugify' {
export interface SlugifyOptions {
separator?: string;
lowercase?: boolean;
}
export default function slugify(text: string, options?: SlugifyOptions): string;
}
declare module '*.svg' { // wildcard: every import ending in .svg
const url: string;
export default url;
}
declare module '*.module.css' { // CSS Modules: class names mapped to generated names
const classes: Readonly<Record<string, string>>;
export default classes;
}
The quick escape hatch declare module 'tiny-slugify'; (with no body) makes every import from the module any. It silences the error, but you lose all checking.
Global augmentation and module augmentation. Thanks to declaration merging (9.1), you can add members to existing types. In a file that is a module, global types are augmented inside declare global, and a package's types inside declare module 'package':
// src/types/globals.d.ts
export {}; // makes this file a module, so the blocks below are augmentations
declare global {
interface Window {
__APP_CONFIG__: { apiUrl: string; featureFlags: Record<string, boolean> };
}
}
declare module 'express-serve-static-core' {
interface Request {
user?: { id: string; role: 'admin' | 'member' }; // added by an authentication middleware (Chapter 12)
}
}
The standard library declarations. The built-in JavaScript and browser APIs are themselves described by declaration files shipped with TypeScript (lib.es2024.d.ts, lib.dom.d.ts and so on). The lib compiler option selects which ones are loaded (9.3), and types selects which @types packages are included automatically. A triple-slash directive can pull in a specific one from a single file:
/// <reference types="vite/client" />
// makes Vite's ambient types (import.meta.env, asset imports) available in this project
Script files and module files in declarations. A .d.ts file without top-level import or export is a global script: everything it declares is visible everywhere. With an import or export, it becomes a module, and you must use declare global to add globals. Mixing these up is the most common reason an augmentation "does nothing".
| Need | Write |
|---|---|
| Types for your own TypeScript library | Nothing: set "declaration": true |
| A JavaScript package without types | npm i -D @types/name, otherwise declare module 'name' { ... } |
Assets imported through a bundler (.svg, .css) | Wildcard declare module '*.svg', or the bundler's client types |
Globals created by <script> tags or the bundler | declare const, or declare global { interface Window { ... } } |
| Extra fields on a library's types | Module augmentation: declare module 'lib' { interface X { ... } } |
Skip type-checking .d.ts files of dependencies | "skipLibCheck": true (faster builds, common in applications) |
Key Takeaways
- Declaration files (
.d.ts) contain only types and describe JavaScript that exists elsewhere; the compiler generates them for your code with"declaration": true. declareintroduces ambient values, functions, modules and globals without emitting code.- Package types come from the package itself, then from
@types/*on DefinitelyTyped, and otherwise from your owndeclare module. - Declaration merging inside
declare globalanddeclare module 'x'augments existing global and library types. - A
.d.tsfile with a top-levelimportorexportis a module, and global declarations in it must go insidedeclare global.
Practice
- (Beginner) In a Vite or plain TypeScript project, import an
.svgfile and read the error. Fix it with a wildcard module declaration and check that the imported value is typed asstring.- (Intermediate) Write a small JavaScript module
currency.jsexportingformat(amount, currency)and aRATESobject. Writecurrency.d.tsnext to it, import the module from a.tsfile withallowJsdisabled, and verify that wrong argument types are reported.- (Interview) "You added a property to
Windowin a.d.tsfile, but the compiler still says it does not exist. What are the likely causes?" Hint: is the file included bytsconfig.json, and is it a script or a module?
<a id="93-typescript-in-practice"></a>
9.3 TypeScript in Practice
This subchapter moves from the type system to real projects: object-oriented code with classes, interfaces and decorators, the module system, configuring the compiler and choosing a strictness level, the build pipeline from .ts to running JavaScript, and typing the browser, Node.js and third-party libraries.
Classes, Access Modifiers and Abstract Classes
JavaScript classes (Chapter 4.3) are syntax for constructor functions and prototypes. TypeScript keeps their runtime behaviour and adds what class-based languages such as Java and C# offer: declared and typed fields, access modifiers, readonly members, abstract classes and checked overrides.
Declared fields and initialisation. In TypeScript every field is declared with its type in the class body. With strictPropertyInitialization (part of strict), each field must be initialised where it is declared or in the constructor:
class Article {
title: string; // assigned in the constructor: OK
views = 0; // initialiser: type number is inferred
tags: string[] = [];
summary?: string; // optional: may stay undefined
body: string;
// error TS2564: Property 'body' has no initializer and is not definitely assigned in the constructor.
publishedAt!: Date; // "!" definite assignment: you promise it is set before use (e.g. by a framework)
constructor(title: string) {
this.title = title;
}
}
Access modifiers. TypeScript has three modifiers, and JavaScript itself now has private fields with #:
class BankAccount {
static readonly bankName = 'TS Bank'; // shared by the class, read-only
readonly id: string; // can be set only in the declaration or the constructor
public owner: string; // public is the default; writing it is optional
protected balance = 0; // this class and its subclasses
private history: number[] = []; // this class only (checked at compile time)
#pin: string; // this class only (enforced at runtime by JavaScript)
constructor(id: string, owner: string, pin: string) {
this.id = id;
this.owner = owner;
this.#pin = pin;
}
deposit(amount: number): void {
this.balance += amount;
this.history.push(amount);
}
checkPin(pin: string): boolean {
return pin === this.#pin;
}
}
const account = new BankAccount('acc-1', 'Ada', '1234');
account.owner = 'Grace'; // OK
account.id = 'acc-2'; // error TS2540: Cannot assign to 'id' because it is a read-only property.
account.balance; // error TS2445: Property 'balance' is protected and only accessible within class 'BankAccount' and its subclasses.
account.history; // error TS2341: Property 'history' is private and only accessible within class 'BankAccount'.
account.#pin; // error TS18013: Property '#pin' is not accessible outside class 'BankAccount' because it has a private identifier.
account['history']; // allowed: bracket access is an intentional escape hatch for TypeScript's private
private (TypeScript) | #field (JavaScript) | |
|---|---|---|
| Enforced | At compile time only | At runtime, by the engine |
| Visible in the emitted JavaScript | Yes, as a normal property | No, truly hidden |
Accessible via obj['name'], Object.keys, JSON.stringify | Yes | No |
| Accessible from other instances of the same class | Yes | Yes |
| Typical use | Encapsulation within a TypeScript code base | Library code, security-relevant state |
Parameter properties. A shorthand that declares and assigns a field directly from a constructor parameter. It is widely used (for example in Angular and NestJS), but it generates code, so it is not erasable syntax (see the compilation topic) and is rejected by Node.js type stripping:
class UserService {
constructor(
private readonly baseUrl: string, // declares this.baseUrl and assigns it
protected timeoutMs = 5000,
) {}
url(path: string): string {
return `${this.baseUrl}${path}`;
}
}
Inheritance and override. Subclasses can redefine methods. The override keyword states the intention explicitly, and the option noImplicitOverride makes it mandatory, so renaming a base method breaks the subclasses that relied on it instead of silently creating a new method:
class Shape {
describe(): string {
return 'a shape';
}
}
class Circle extends Shape {
constructor(public radius: number) {
super();
}
override describe(): string { // checked: Shape really has describe()
return `a circle of radius ${this.radius}`;
}
override describ(): string {
// error TS4117: This member cannot have an 'override' modifier because it is not declared in the base class 'Shape'.
// Did you mean 'describe'?
return '';
}
}
Abstract classes. An abstract class is a partial implementation: it cannot be instantiated, and it declares abstract members that subclasses must implement. It fits the template method pattern, where the base class fixes an algorithm and subclasses fill in the steps:
abstract class Exporter<Row> {
constructor(protected readonly rows: Row[]) {}
protected abstract header(): string; // no body: subclasses must implement it
protected abstract formatRow(row: Row): string;
export(): string { // template method: the algorithm is fixed here
return [this.header(), ...this.rows.map((r) => this.formatRow(r))].join('\n');
}
}
type Product = { name: string; price: number };
class CsvExporter extends Exporter<Product> {
protected header(): string {
return 'name,price';
}
protected formatRow(p: Product): string {
return `${p.name},${p.price}`;
}
}
const csv = new CsvExporter([{ name: 'Pen', price: 1.5 }]).export(); // "name,price\nPen,1.5"
const base = new Exporter<Product>([]);
// error TS2511: Cannot create an instance of an abstract class.
class JsonExporter extends Exporter<Product> {
// error TS2515: Non-abstract class 'JsonExporter' does not implement inherited abstract member formatRow from class 'Exporter<Product>'.
protected header(): string {
return '[';
}
}
Classes are structural too. Two classes with the same public shape are interchangeable as types, even if they are unrelated. A private or protected member changes this: only instances of the class that declared it (or its subclasses) are compatible.
class Celsius {
constructor(public value: number) {}
}
class Meters {
constructor(public value: number) {}
}
const distance: Meters = new Celsius(20); // allowed: same shape { value: number }
Key Takeaways
- Fields are declared with types, and
strictPropertyInitializationrequires them to be initialised;!is an unchecked promise that they will be. public,protectedandprivateare compile-time checks only;#privatefields are enforced by the JavaScript engine at runtime.readonlyfields can be assigned only in their declaration or the constructor; parameter properties declare and assign fields in one step but are not erasable syntax.overridetogether withnoImplicitOverridekeeps subclasses in sync with their base classes.- Abstract classes cannot be instantiated and force subclasses to implement abstract members; they fit the template method pattern.
Practice
- (Beginner) Write a
Counterclass with aprivatecount,increment()andreset()methods and areadonlyname. Try to changecountandnamefrom outside the class, then convertcountto a#countfield and compare the emitted JavaScript.- (Intermediate) Implement an abstract
Notifierclass with an abstractsend(to: string, message: string): Promise<void>and a concretenotifyAll(recipients: string[], message: string)that sends to everyone and collects failures. AddEmailNotifierandSmsNotifiersubclasses.- (Interview) "What is the difference between TypeScript's
privateand JavaScript's#private, and when would you choose each?" Hint: think about compile time vs runtime, whatJSON.stringifyandObject.keyssee, and code that is consumed from plain JavaScript.
Implementing Interfaces and Decorators
Interfaces describe what an object can do; classes describe how. The implements clause connects the two, which is the basis of loosely coupled, testable designs. Decorators, the second topic here, attach reusable behaviour such as logging, caching or framework registration to classes and their members.
implements. A class that implements an interface is checked to provide every member of that interface with a compatible type. A class can implement several interfaces:
interface Logger {
info(message: string): void;
error(message: string, cause?: unknown): void;
}
interface Disposable {
dispose(): void;
}
class ConsoleLogger implements Logger, Disposable {
info(message: string): void {
console.log(`[info] ${message}`);
}
error(message: string, cause?: unknown): void {
console.error(`[error] ${message}`, cause);
}
dispose(): void {}
}
class BrokenLogger implements Logger {
// error TS2420: Class 'BrokenLogger' incorrectly implements interface 'Logger'.
// Property 'error' is missing in type 'BrokenLogger' but required in type 'Logger'.
info(message: string): void {}
}
implements is only a check. It does not change the class's type, and it does not give the method parameters their types:
class LazyLogger implements Logger {
info(message) {} // error TS7006: Parameter 'message' implicitly has an 'any' type.
error(message: string): void {}
}
Programming to interfaces. Code that depends on an interface instead of a concrete class can be given any implementation: a real one in production, a fake one in tests. This is the dependency inversion principle, and it is the core idea behind dependency injection frameworks:
interface Clock {
now(): Date;
}
class SignupService {
constructor(
private readonly logger: Logger, // depends on abstractions, not on ConsoleLogger
private readonly clock: Clock,
) {}
register(email: string): { email: string; registeredAt: Date } {
const registeredAt = this.clock.now();
this.logger.info(`New signup: ${email}`);
return { email, registeredAt };
}
}
// Production wiring
const service = new SignupService(new ConsoleLogger(), { now: () => new Date() });
// Test wiring: plain objects satisfy the interfaces structurally
const lines: string[] = [];
const testService = new SignupService(
{ info: (m) => lines.push(m), error: () => {} },
{ now: () => new Date('2026-01-01T00:00:00Z') },
);
testService.register('ada@example.com'); // lines: ['New signup: ada@example.com']
| Interface | Abstract class | |
|---|---|---|
| Exists at runtime | No | Yes (it is a class) |
| Can contain implementation | No | Yes |
| Can contain state (fields with values) | No | Yes |
| A class can use | Any number (implements A, B) | Only one (extends) |
| Satisfied by plain objects | Yes, structurally | Only if they match its public shape (and it has no private members) |
| Use when | Defining a contract between parts of the system | Sharing code between closely related classes |
Decorators. A decorator is a function, applied with @name before a class or class member, that receives the decorated element and can observe, replace or extend it. The idea comes from the decorator design pattern: wrapping behaviour around existing code without modifying it.
Analogy: a decorator is like a frame put around a painting. The painting (the method) stays the same, but the frame adds something around it, such as a label, lighting or an alarm (logging, timing, retries).
There are two incompatible decorator systems in TypeScript:
| Standard decorators | Legacy ("experimental") decorators | |
|---|---|---|
| Specification | TC39 decorators proposal (stage 3) | Early TypeScript design from 2015 |
| Enabled by | Default since TypeScript 5.0 | "experimentalDecorators": true |
| Decorator signature | (value, context) => replacement | (target, propertyKey, descriptor) |
| Parameter decorators | No | Yes |
Type metadata (emitDecoratorMetadata) | No | Yes |
| Used by | New code, recent library versions | Angular, NestJS, TypeORM, and other established frameworks |
A standard method decorator. The decorator receives the original method and a context object, and returns a replacement method:
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
const name = String(context.name);
return function (this: This, ...args: Args): Return {
console.log(`-> ${name}(${args.map((a) => JSON.stringify(a)).join(', ')})`);
const result = target.call(this, ...args); // call the original method
console.log(`<- ${name}`);
return result;
};
}
class Calculator {
@logged
add(a: number, b: number): number {
return a + b;
}
}
new Calculator().add(2, 3);
// -> add(2, 3)
// <- add
Decorator factories. To configure a decorator, write a function that returns a decorator. Here, a retry policy for asynchronous methods:
function retry(times: number) {
return function <This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Promise<Return>,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Promise<Return>>,
) {
return async function (this: This, ...args: Args): Promise<Return> {
let lastError: unknown;
for (let attempt = 1; attempt <= times; attempt++) {
try {
return await target.call(this, ...args);
} catch (err) {
lastError = err;
console.warn(`${String(context.name)} failed (attempt ${attempt}/${times})`);
}
}
throw lastError;
};
};
}
class PriceApi {
@retry(3) // the factory is called once, with 3
async latest(symbol: string): Promise<number> {
const res = await fetch(`/api/prices/${symbol}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as number;
}
}
Initialisers and auto-binding. context.addInitializer runs code when each instance is constructed. This decorator binds a method to its instance, so it keeps the correct this when passed as an event listener (Chapter 6.3):
function bound(_target: unknown, context: ClassMethodDecoratorContext) {
const name = context.name;
context.addInitializer(function (this: any) {
this[name] = this[name].bind(this);
});
}
class Toolbar {
clicks = 0;
@bound
handleClick(): void {
this.clicks++; // `this` is the Toolbar, even when called as a detached function
}
}
const toolbar = new Toolbar();
document.querySelector('#save')?.addEventListener('click', toolbar.handleClick);
Legacy decorators in frameworks. In Angular or NestJS code you will meet the legacy form, often with constructor parameter decorators and runtime type metadata used for dependency injection:
// Requires "experimentalDecorators": true and "emitDecoratorMetadata": true (NestJS example)
import { Controller, Get, Param } from '@nestjs/common';
@Controller('users') // class decorator: registers a route prefix
export class UsersController {
constructor(private readonly users: UsersService) {} // injected using emitted metadata
@Get(':id') // method decorator: maps GET /users/:id
findOne(@Param('id') id: string) { // parameter decorator: legacy decorators only
return this.users.findById(id);
}
}
Decorators generate code, so they need a compiler (TypeScript, Babel, SWC or esbuild) to transform them; type-stripping tools cannot run them.
Key Takeaways
implementschecks that a class satisfies one or more interfaces, but does not change the class's type or infer parameter types.- Depending on interfaces instead of concrete classes enables dependency injection and simple test fakes.
- Use interfaces for contracts and abstract classes for shared implementation; a class can implement many interfaces but extend only one class.
- Decorators are functions applied with
@that wrap or annotate classes and members; factories (@retry(3)) configure them. - Standard decorators (TypeScript 5.0+) and legacy decorators (
experimentalDecorators) are incompatible; frameworks such as Angular and NestJS still use the legacy form.
Practice
- (Beginner) Define an interface
Cache<V>withget,setanddelete, and implement it twice:MemoryCachewith aMap, andStorageCachewithlocalStorageand JSON serialisation.- (Intermediate) Write a standard
@memoizemethod decorator that caches results by the JSON of the arguments, and a@timed(label)decorator factory that logs how long each call takes. Apply both to the same method and observe the order in which they run.- (Interview) "How do interfaces make code easier to test? Show it with a class that depends on the current time and on an HTTP client." Hint: constructor injection of a
Clockand anHttpClientinterface, with fakes in the test.
Modules and Namespaces
TypeScript uses the standard ES module system of Chapter 3.4: import and export, one module per file. It adds type-only imports and exports, has to work out how module specifiers map to files (module resolution), and still supports namespaces, its older way of organising code.
Scripts vs modules. A file with a top-level import or export is a module, with its own scope. A file without either is a script, and its top-level declarations are global. Two script files that both declare const config therefore produce error TS2451: Cannot redeclare block-scoped variable 'config'. Add export {} to turn a file into a module, or set "moduleDetection": "force" (as tsc --init does) to treat every file as a module.
Importing and exporting types. Types can be exported and imported like values. Because types disappear in the emitted JavaScript, TypeScript lets you mark imports that are only types:
// api.ts
export interface User {
id: string;
name: string;
}
export type UserId = User['id'];
export async function fetchUser(id: UserId): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return (await res.json()) as User;
}
// profile.ts
import { fetchUser, type User } from './api.js'; // inline "type": this name is erased
import type { UserId } from './api.js'; // the whole import is erased
export type { User }; // re-export a type only
export async function showProfile(id: UserId): Promise<User> {
return fetchUser(id);
}
Why type-only imports matter. Fast transpilers (esbuild, SWC, Babel, Node.js type stripping) process one file at a time and cannot know whether an imported name is a type, which must be removed, or a value, which must be kept. Marking type imports removes the guesswork. Two options enforce it:
| Option | Effect |
|---|---|
isolatedModules | Reports code that a single-file transpiler cannot compile correctly (for example re-exporting a type without export type) |
verbatimModuleSyntax | Imports and exports are emitted exactly as written, minus anything marked type; importing a type without type is an error |
// With "verbatimModuleSyntax": true
import { User } from './api.js';
// error TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
Module resolution. Given import { x } from './utils.js' or from 'lodash-es', the compiler must find the file that provides the types. The module and moduleResolution options tell it which runtime or tool will resolve the import later, so that it follows the same rules:
| Setting | Models | Relative imports | Typical project |
|---|---|---|---|
"module": "nodenext" | Node.js's real rules for ESM and CommonJS | Need the runtime extension: './utils.js' for utils.ts | Node.js applications and libraries |
"moduleResolution": "bundler" with "module": "esnext" or "preserve" | Vite, webpack, esbuild and similar tools | Extensions optional: './utils' | Front-end applications built with a bundler |
// tsconfig: "module": "nodenext", and package.json has "type": "module"
import { slugify } from './utils';
// error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when
// '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './utils.js'?
import { slugify as ok } from './utils.js'; // correct: refers to the emitted utils.js, type-checked against utils.ts
If you prefer writing ./utils.ts, enable allowImportingTsExtensions (with noEmit, for bundlers and runtimes that run .ts directly) or rewriteRelativeImportExtensions (when tsc emits JavaScript, it rewrites .ts to .js).
Path aliases. Long relative paths such as '../../../lib/date' can be replaced by aliases. The paths option only teaches the type checker about them; the bundler or runtime must be configured to resolve them too. The standard alternative that works everywhere is Node.js subpath imports in package.json:
{
"name": "shop",
"type": "module",
"imports": {
"#lib/*": "./src/lib/*"
}
}
import { formatDate } from '#lib/date.js'; // understood by Node.js, bundlers and TypeScript alike
CommonJS interoperability. Many npm packages are still CommonJS (module.exports = ...). TypeScript lets you import them with ES syntax (import express from 'express'); this default-import interoperability (formerly the esModuleInterop option) is always enabled in TypeScript 7. The older form import fs = require('fs') still exists for CommonJS output but generates code and is not erasable.
Namespaces. Before ES modules existed, TypeScript provided internal modules, now called namespaces: named objects that group related code and hide what is not exported.
namespace Validation {
const lettersRegexp = /^[A-Za-z]+$/; // not exported: private to the namespace
export function isLetters(s: string): boolean {
return lettersRegexp.test(s);
}
export const MAX_LENGTH = 64;
}
Validation.isLetters('Hello'); // true
Validation.lettersRegexp; // error TS2339: Property 'lettersRegexp' does not exist on type 'typeof Validation'.
A namespace compiles to an object built by an immediately invoked function (Chapter 3.3), the same module pattern that was common in pre-ES2015 JavaScript:
var Validation;
(function (Validation) {
const lettersRegexp = /^[A-Za-z]+$/;
function isLetters(s) {
return lettersRegexp.test(s);
}
Validation.isLetters = isLetters;
Validation.MAX_LENGTH = 64;
})(Validation || (Validation = {}));
Namespaces are legacy in application code: ES modules do the same job, work with every tool, and support tree shaking (Chapter 3.4). They remain useful in declaration files, where they describe libraries that expose a global object (for example declare namespace google.maps { ... }), and for grouping types such as NodeJS.ProcessEnv.
| ES modules | Namespaces | |
|---|---|---|
| Standard | JavaScript (ES2015) | TypeScript only |
| Unit | One file | A named block, possibly spread across files |
| Runtime output | import/export kept | An object built by an IIFE |
| Tree shaking, bundlers, Node.js | Fully supported | Poorly supported; not erasable syntax |
| Use today | All new code | Declaration files for global libraries |
Key Takeaways
- A file with a top-level
importorexportis a module; without one, its declarations are global. import typeand inlinetypemodifiers mark imports that are erased;verbatimModuleSyntaxandisolatedModulesmake code safe for single-file transpilers.- Choose
module: "nodenext"for code run by Node.js (relative imports need.jsextensions) andmoduleResolution: "bundler"for code processed by a bundler. pathsonly affects type checking; subpath imports (#lib/*inpackage.json) work with every tool.- Namespaces are a legacy, non-erasable way to organise code; use ES modules, and keep namespaces for declaration files.
Practice
- (Beginner) Split a small program into
models.ts(only interfaces),service.ts(functions) andmain.ts. Useimport typefor the models and enableverbatimModuleSyntaxto check that you did it correctly.- (Intermediate) Create the same two-file project twice: once with
"module": "nodenext"and run withnode, once with"moduleResolution": "bundler"and built with Vite. Note which import specifiers each one accepts, and why.- (Interview) "Why do fast transpilers such as esbuild need
isolatedModules, and what kind of code breaks without it?" Hint: a transpiler that sees one file at a time cannot tell whetherexport { User } from './api're-exports a type or a value.
tsconfig and Compiler Options
A TypeScript project is defined by a tsconfig.json file: which files belong to it, which environment the code runs in, how strictly it is checked and what, if anything, the compiler emits. Running tsc without arguments looks for tsconfig.json in the current directory and uses it. Editors read the same file, so the editor and the build agree.
Structure of the file. tsconfig.json allows comments and trailing commas:
{
"extends": "@tsconfig/node24/tsconfig.json", // inherit a shared base configuration
"compilerOptions": {
// ...options, grouped below
},
"include": ["src"], // files to compile (globs); defaults to everything
"exclude": ["**/*.test.ts"], // removed from "include" (not from imports)
"files": [], // an explicit list, for small projects
"references": [{ "path": "../shared" }] // project references (monorepos)
}
The main option groups.
| Group | Option | Purpose |
|---|---|---|
| Type checking | strict and its family | How strictly code is checked (next topic) |
| Language and environment | target | Which JavaScript syntax version to emit (es2022, esnext...) |
lib | Which built-in API declarations exist (es2024, dom, dom.iterable, webworker) | |
types | Which @types/* packages are loaded globally (["node"]) | |
jsx | How to handle JSX (react-jsx, preserve) | |
| Modules | module, moduleResolution | Module format and resolution rules (previous topic) |
resolveJsonModule | Allows import data from './data.json' with inferred types | |
paths | Import aliases for the type checker | |
| Emit | outDir, rootDir | Where emitted files go, and the source root they mirror |
declaration, declarationMap | Emit .d.ts files (and maps back to the source) | |
sourceMap | Emit .js.map files for debugging | |
noEmit | Only type-check (another tool produces the JavaScript) | |
noEmitOnError | Do not write output if there are type errors | |
| Interop | isolatedModules, verbatimModuleSyntax, erasableSyntaxOnly | Compatibility with single-file transpilers and type stripping |
allowJs, checkJs | Include and optionally check JavaScript files | |
| Performance | skipLibCheck | Do not re-check dependencies' .d.ts files |
incremental, composite | Cache results between builds; enable project references |
target vs lib. These two are often confused:
target: "es2017" -> the OUTPUT syntax: a?.b is rewritten to a === null || a === void 0 ? void 0 : a.b
lib: ["es2024"] -> the APIs you may CALL: Object.groupBy, Array.prototype.findLast ... are allowed
Neither adds polyfills: if lib promises Object.groupBy but the browser lacks it,
the code type-checks and still fails at runtime. Polyfills are a separate concern (Chapter 1.3).
Example: a browser application built with Vite. The bundler produces the JavaScript, so tsc only checks types:
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2023", "dom", "dom.iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"], // import.meta.env, asset imports
"noEmit": true, // Vite emits; tsc --noEmit runs in CI and the editor
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["src"]
}
Example: a Node.js library published to npm. Here tsc emits JavaScript and declaration files:
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"], // no "dom": this code runs in Node.js
"module": "nodenext", // also sets moduleResolution: nodenext
"types": ["node"],
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
Defaults and recent changes. TypeScript 7 (2026), the native rewrite of the compiler, changed several defaults: strict is on, target and module default to esnext, and types defaults to an empty list, so @types/node must be listed explicitly. Legacy options that were deprecated in TypeScript 6.0 have been removed, including target: "es5", moduleResolution: "node10" and "classic", baseUrl, outFile, the amd, umd and system module formats and esModuleInterop: false. Old tutorials often still use them, so expect to update configurations copied from older projects.
Shared bases and project references. The @tsconfig/bases project publishes recommended configurations per runtime (@tsconfig/node24, @tsconfig/strictest and others) to extends from. Large repositories split into several projects with "composite": true and references, and build them in dependency order with tsc --build (tsc -b), which only rebuilds what changed.
packages/
shared/tsconfig.json "composite": true
api/tsconfig.json "references": [{ "path": "../shared" }]
web/tsconfig.json "references": [{ "path": "../shared" }]
tsconfig.json "files": [], "references": [ shared, api, web ] -> tsc -b builds all three
Useful diagnostic commands.
| Command | Shows |
|---|---|
tsc --init | Creates a commented tsconfig.json with recommended settings |
tsc --showConfig | The final configuration after extends and defaults |
tsc --explainFiles | Every file in the program and why it was included |
tsc --traceResolution | How each import was resolved to a file |
tsc --noEmit --watch | Continuous type checking without output |
tsc --extendedDiagnostics | Time and memory per compilation phase |
Key Takeaways
tsconfig.jsondefines a project: its files (include,exclude), its environment (target,lib,types,module) and its checking and emit options.targetcontrols the emitted syntax andlibthe APIs the code may use; neither provides polyfills.- Front-end projects usually type-check with
noEmitand let a bundler emit; libraries emit JavaScript,.d.tsfiles and source maps withtsc. - TypeScript 7 enables
strictby default, defaultstargetandmoduletoesnext, and removes legacy options such ases5output,baseUrlandoutFile. extendsshares configuration, and project references withtsc -bstructure large repositories into incremental builds.
Practice
- (Beginner) Run
tsc --initin an empty folder, read every option it enabled, and usetsc --showConfigto see the effective configuration. Add"lib": ["es2020"]and try callingObject.groupBy.- (Intermediate) Create a small Node.js package with the library configuration above, build it, and inspect
dist/: the.js,.d.ts,.d.ts.mapand.js.mapfiles. Import it from a second project and use "Go to Definition" on one of its functions.- (Interview) "A project compiles without errors, but crashes in an older browser with
Object.groupBy is not a function. Which configuration options are involved, and how do you fix it?" Hint: separate whattargetandlibpromise from what the browser actually provides.
Strict Mode and Type Safety Levels
TypeScript is gradual: the same compiler can accept almost any JavaScript, or reject everything that is not provably safe. Where a project sits on that spectrum is decided by configuration. The strict flag is the most important switch, but it is only one level on a longer ladder.
Why strictness is configurable. When TypeScript appeared, its users had large JavaScript code bases to migrate. Enforcing null checks or forbidding implicit any from the first day would have produced thousands of errors. Each strict check was therefore introduced as a separate opt-in flag, and strict was added as a single switch for the whole family. In TypeScript 7, strict is on by default.
The strict family. "strict": true enables all of these at once (and future strict checks automatically):
| Flag | What it catches | Example |
|---|---|---|
noImplicitAny | Parameters and variables whose type would silently be any | function f(x) {} |
strictNullChecks | Use of possibly null/undefined values | document.querySelector('a').href |
strictFunctionTypes | Unsafe callback parameter types (see 9.2) | Passing a Dog handler where an Animal handler is required |
strictBindCallApply | Wrong arguments to bind, call and apply | fn.call(undefined, 'x') when fn expects a number |
strictPropertyInitialization | Class fields that are never initialised | name: string; with no assignment |
noImplicitThis | this of implicit type any | this inside a standalone function |
useUnknownInCatchVariables | Treats catch (err) as unknown instead of any | err.message without a check |
strictBuiltinIteratorReturn | Iterator results from built-ins typed precisely | iterator.next().value may be undefined |
alwaysStrict | Emits "use strict" (always on in TypeScript 7) | Strict mode JavaScript semantics |
The catch variable case is one of the most common in everyday code:
async function loadConfig(): Promise<string> {
try {
const res = await fetch('/config.json');
return await res.text();
} catch (err) {
console.error(err.message); // error TS18046: 'err' is of type 'unknown'.
if (err instanceof Error) console.error(err.message); // OK
return '{}';
}
}
Beyond strict. Some checks are not included in strict because they require more code changes, but they catch real bugs:
| Flag | What it does |
|---|---|
noUncheckedIndexedAccess | Index access on arrays and records includes undefined |
exactOptionalPropertyTypes | prop?: string means "missing or a string", and assigning undefined explicitly is an error |
noImplicitOverride | Requires override on overriding methods |
noImplicitReturns | Every code path of a function must return a value |
noFallthroughCasesInSwitch | Forbids non-empty case blocks without break or return |
noPropertyAccessFromIndexSignature | Requires obj['key'] instead of obj.key for index-signature properties |
noUnusedLocals, noUnusedParameters | Reports unused declarations (often left to a linter instead) |
// With "noUncheckedIndexedAccess": true
const scores: Record<string, number> = { ada: 90 };
const s = scores['grace']; // number | undefined
s.toFixed(1); // error TS18048: 's' is possibly 'undefined'.
const items = ['a', 'b'];
const third = items[2]; // string | undefined
for (const item of items) {
item.toUpperCase(); // OK: iteration never yields a missing element
}
// With "exactOptionalPropertyTypes": true
interface Options {
label?: string;
}
const a: Options = {}; // OK: the property is missing
const b: Options = { label: undefined };
// error TS2375: Type '{ label: undefined; }' is not assignable to type 'Options' with 'exactOptionalPropertyTypes: true'.
// Consider adding 'undefined' to the types of the target's properties.
A ladder of type safety levels.
Level 0 Plain JavaScript + // @ts-check or "checkJs" JSDoc comments give types; no build step changes
Level 1 TypeScript, "strict": false, "allowJs" Migration in progress; implicit any accepted
Level 2 "strict": true The baseline for new projects (default in TypeScript 7)
Level 3 strict + noUncheckedIndexedAccess, Index access, optional properties and overrides
exactOptionalPropertyTypes, noImplicitOverride are also checked
Level 4 Level 3 + typescript-eslint "strict-type-checked" Lint rules forbid explicit any, unsafe any flows,
floating promises, unnecessary conditions
Level 5 Level 4 + runtime validation at every boundary Network, storage and user input are verified
Level 0 deserves a closer look, because it brings type checking to projects that cannot add a build step:
// @ts-check
/**
* @param {number} price
* @param {number} [quantity=1]
* @returns {string}
*/
function formatTotal(price, quantity = 1) {
return (price * quantity).toFixed(2);
}
formatTotal('9.99'); // error: Argument of type 'string' is not assignable to parameter of type 'number'.
Escape hatches, ranked by risk. Strict code still needs occasional overrides. Prefer the safer ones and make every use visible in code review:
| Escape hatch | Effect | Risk |
|---|---|---|
// @ts-expect-error reason | Suppresses an error on the next line, and reports an error if there is none | Low: becomes visible once the problem is fixed |
value satisfies T | Checks without changing the type | None |
value as T | Unchecked assertion | Medium: wrong if the data does not match |
value! | Removes null and undefined | Medium: a TypeError if wrong |
// @ts-ignore | Suppresses any error on the next line, forever | High: hides future errors too |
any | Disables checking and spreads to everything it touches | Highest |
Migrating a code base to strict. Turning on every flag at once can produce thousands of errors. A proven approach:
- Enable
strictand add one flag at a time if the full set is too much, starting withnoImplicitAny, thenstrictNullChecks. - Fix errors file by file or directory by directory; in large projects, temporarily suppress the remaining errors with
// @ts-expect-errorcomments so that new code is checked strictly from day one. - Ratchet: track the number of suppressions in CI and allow it only to go down.
- Add lint rules (
@typescript-eslint/no-explicit-any,no-floating-promises) once the compiler errors are gone.
The runtime boundary. No compiler flag checks data that arrives at runtime. The strictest projects validate input with a schema library and derive the static type from the schema, so the two cannot drift apart:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().nonnegative().optional(),
});
type User = z.infer<typeof UserSchema>; // { id: string; email: string; age?: number | undefined }
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json()); // throws a detailed error if the data is wrong
}
Key Takeaways
strictenables a family of checks (noImplicitAny,strictNullChecks,strictFunctionTypesand others) and is the default in TypeScript 7.noUncheckedIndexedAccess,exactOptionalPropertyTypesandnoImplicitOverrideare valuable checks outsidestrict.- Type safety is a ladder: from
// @ts-checkwith JSDoc, throughstrict, to type-aware lint rules and runtime validation. - Prefer
@ts-expect-errorover@ts-ignore, andsatisfiesoveras; treatanyas the last resort. - Static types end at runtime boundaries; validate external data with a schema and derive the type from it.
Practice
- (Beginner) Take a 30-line JavaScript file, add
// @ts-checkand JSDoc annotations, and fix the errors the editor reports. Then rename it to.tsand replace the JSDoc with TypeScript annotations.- (Intermediate) In an existing project, enable
noUncheckedIndexedAccess. Count the new errors, fix five of them in different ways (a guard, a default value,Array.prototype.atwith a check, restructuring the loop), and decide whether to keep the flag.- (Interview) "You join a team whose large code base has
strict: falseand around 400 uses ofany. How do you improve type safety without stopping feature work?" Hint: incremental flags, suppressions with a ratchet in CI, lint rules for new code, and focusing first on the boundaries where data enters the system.
Compilation, Transpilation and Source Maps
Browsers and Node.js run JavaScript, so TypeScript always goes through a build step, even if it is hidden inside a tool. Understanding that step explains why type errors and runtime errors are separate, why fast tools skip type checking, and how a debugger can show TypeScript lines while running JavaScript.
Two jobs, often done by different tools.
- Type checking needs the whole program: every file, every
.d.ts, every import. It is the slow part. - Transpiling (source-to-source compilation) removes types and optionally rewrites newer syntax for older runtimes. It can be done one file at a time, and very quickly.
+------------------------------------------+
src/*.ts ------------> | tsc --noEmit (type checking only) | ---> errors in the editor, CI
| +------------------------------------------+
|
| +------------------------------------------+
+------------------> | Transpiler: esbuild, SWC, Babel, tsc, | ---> *.js (+ *.js.map)
| or a runtime that strips types (Node.js,| runs in the browser or Node.js
| Deno, Bun) |
+------------------------------------------+
Transpilers do not check types: code with type errors still runs.
| Tool | Type checks | Speed | Typical use |
|---|---|---|---|
tsc | Yes | Fast since TypeScript 7 (native, parallel); slower in 5.x/6.x | Libraries, CI checks, .d.ts generation |
| esbuild, SWC, Oxc | No | Very fast | Inside Vite, webpack, Next.js and test runners |
Babel (@babel/preset-typescript) | No | Moderate | Existing Babel pipelines |
| Node.js type stripping | No | Instant (types replaced by spaces) | Scripts, servers, tooling |
| Deno, Bun | No by default (deno check checks) | Instant | Runtimes with built-in TypeScript support |
The usual setup is therefore: a fast transpiler in development and in the bundle, plus tsc --noEmit in the editor and in CI as the gatekeeper.
Downlevelling. The target option decides which syntax the output may use. Newer syntax is rewritten for older targets:
// input
const city = user?.address?.city ?? 'unknown';
// output with "target": "es2019" (no optional chaining or ?? yet)
var _a;
var _b;
const city = (_b = (_a = user === null || user === void 0 ? void 0 : user.address) === null || _a === void 0 ? void 0 : _a.city) !== null && _b !== void 0 ? _b : 'unknown';
// output with "target": "es2022": unchanged apart from removed types
Some features need helper functions in the output (for example class private fields for old targets). importHelpers imports them from the tslib package instead of copying them into every file. Downlevelling rewrites syntax only; missing APIs such as Array.prototype.findLast require polyfills (see target vs lib in the previous topics).
Erasable syntax and type stripping. Most TypeScript syntax is erasable: deleting it leaves valid JavaScript with the same behaviour. A few older features generate code instead, and tools that only strip types cannot handle them:
| Erasable (types only) | Not erasable (generates code) |
|---|---|
Type annotations, interface, type | enum (except declare enum) |
Generics, as, satisfies, ! | namespace with values |
import type, export type | Parameter properties: constructor(private x: number) |
declare statements | import x = require('...'), export = |
| Access modifiers on normal fields | Decorators (need a transform) |
Node.js 22.18 and later (and Node.js 24) run .ts files directly by replacing type annotations with whitespace, so line and column positions do not change and no source maps are needed:
node server.ts # runs, as long as the file only uses erasable syntax
node --experimental-transform-types legacy.ts # also transforms enums, namespaces, parameter properties
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
Setting "erasableSyntaxOnly": true makes tsc report non-erasable syntax as errors, so code stays runnable by type-stripping tools:
// With "erasableSyntaxOnly": true
enum Color { Red, Green }
// error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
Source maps. After transpiling, bundling and minifying, the JavaScript that runs looks nothing like the TypeScript you wrote. A source map is a JSON file that maps each position in the generated code back to a file, line and column in the original source. Browser developer tools and Node.js read it to show stack traces and breakpoints in TypeScript.
dist/app.js dist/app.js.map src/app.ts
----------------------------- ------------------------------------ --------------------------
"use strict"; { const greet = (n: string) =>
const greet = (n) => `Hi ${n}`; <---- "version": 3, ----> `Hi ${n}`;
... "file": "app.js",
//# sourceMappingURL=app.js.map "sources": ["../src/app.ts"],
^ link from the code to its map "sourcesContent": ["...original..."], (only with inlineSources)
"mappings": "AAAA,MAAM,KAAK,GAAG..." <- Base64 VLQ segments:
} generated column ->
source file, line, column
| Option | Effect |
|---|---|
sourceMap | Writes app.js.map next to app.js and adds the sourceMappingURL comment |
inlineSourceMap | Embeds the map as a base64 data: URL inside app.js (convenient, but larger files) |
inlineSources | Puts the original TypeScript into the map, so the debugger needs no access to src/ |
declarationMap | Maps .d.ts files back to the source, so "Go to Definition" opens the .ts file of a library |
Node.js does not apply source maps to stack traces unless asked:
node --enable-source-maps dist/server.js
# Error: Invalid order
# at createOrder (/app/src/orders.ts:42:11) <- original TypeScript position instead of dist/orders.js
In production, a common practice is to generate source maps but not serve them publicly ("hidden" source maps), and upload them to an error-tracking service that uses them to de-minify stack traces.
Watch mode and incremental builds. tsc --watch keeps the compiler running and re-checks only what changed. incremental stores information about the previous build in a .tsbuildinfo file, so the next tsc run starts faster, and tsc -b uses it across project references. TypeScript 7 is a native port of the compiler (written in Go) that checks files in parallel. It is typically around ten times faster than the JavaScript-based 5.x compiler, which makes running full type checks on every save practical even in large repositories.
Key Takeaways
- Type checking (whole program, slower) and transpiling (per file, fast) are separate jobs; fast transpilers do not check types, so run
tsc --noEmitin CI. targetcontrols downlevelling of syntax; missing runtime APIs need polyfills, not a lowertarget.- Erasable syntax can be removed without changing behaviour; enums, namespaces, parameter properties and decorators generate code and break type stripping.
erasableSyntaxOnlyenforces the difference. - Node.js 22.18+ runs erasable
.tsfiles directly; Deno and Bun also run TypeScript without a separate build step. - Source maps link generated JavaScript back to TypeScript for debuggers and stack traces; use
--enable-source-mapsin Node.js and keep production maps private.
Practice
- (Beginner) Compile a file that uses optional chaining,
??and a class with a#privatefield with--target es2017and with--target es2022. Compare the two outputs and explain each difference.- (Intermediate) Build a small Node.js project with
sourceMap: true, throw an error inside a nested function, and run it with and without--enable-source-maps. Then open the.js.mapfile and identifysources,namesandmappings.- (Interview) "Our Vite application builds successfully even though it contains type errors. Why, and how should the pipeline be set up?" Hint: which tool inside Vite transforms TypeScript, what it does with types, and where
tsc --noEmitbelongs.
Typing DOM, Node and Third-Party Libraries
Most TypeScript code spends its time calling APIs it did not define: the browser's DOM, Node.js modules and npm packages. All of them are typed through declaration files, and each has its own patterns and pitfalls.
The DOM. The browser APIs are declared in lib.dom.d.ts, enabled by including "dom" (and "dom.iterable" for for...of over NodeList) in lib. Their types follow the interface hierarchy of Chapter 6.1:
EventTarget
└─ Node
└─ Element
└─ HTMLElement
├─ HTMLInputElement (value, checked, files ...)
├─ HTMLAnchorElement (href, download ...)
├─ HTMLCanvasElement (getContext ...)
└─ ... one interface per tag, mapped in HTMLElementTagNameMap
Selecting elements. The return type depends on how you select:
const link = document.querySelector('a'); // HTMLAnchorElement | null (tag name: precise)
const field = document.querySelector('#email'); // Element | null (a CSS selector string: generic)
const byId = document.getElementById('email'); // HTMLElement | null
// Option 1: generic type argument (an unchecked claim, like "as")
const email = document.querySelector<HTMLInputElement>('#email');
// Option 2: an instanceof check (verified at runtime)
const el = document.getElementById('email');
if (el instanceof HTMLInputElement) {
console.log(el.value); // el: HTMLInputElement
}
// Option 3: a small helper that fails fast with a clear message
function $<T extends Element>(selector: string, type: new () => T): T {
const node = document.querySelector(selector);
if (!(node instanceof type)) throw new Error(`${selector} is not a ${type.name}`);
return node;
}
const amount = $('#amount', HTMLInputElement); // HTMLInputElement, verified
const items = document.querySelectorAll('li.item'); // NodeListOf<Element>
Events. addEventListener uses event maps to type the event object from the event name. The target of an event, however, is only typed as EventTarget | null, because the event may come from any descendant (event delegation, Chapter 6.3):
const form = document.querySelector('form')!;
form.addEventListener('submit', (event) => { // event: SubmitEvent
event.preventDefault();
const data = new FormData(form);
console.log(data.get('email')); // FormDataEntryValue | null
});
document.addEventListener('keydown', (e) => { // e: KeyboardEvent
if (e.key === 'Escape') closeDialog();
});
const list = document.querySelector('ul')!;
list.addEventListener('click', (e) => {
e.currentTarget; // EventTarget | null: the ul the listener is on
e.target.closest('li');
// error TS18047: 'e.target' is possibly 'null'.
// error TS2339: Property 'closest' does not exist on type 'EventTarget'. (a target may be a non-Element node)
if (e.target instanceof Element) {
const item = e.target.closest('li'); // HTMLLIElement | null
item?.classList.toggle('done');
}
});
function closeDialog(): void {}
Custom events. Declare the detail type with CustomEvent<T>, and add your event to the global event map so that listeners are typed automatically:
export {}; // a module, so "declare global" is allowed
interface CartUpdatedDetail {
itemCount: number;
total: number;
}
declare global {
interface DocumentEventMap {
'cart:updated': CustomEvent<CartUpdatedDetail>;
}
}
document.dispatchEvent(
new CustomEvent<CartUpdatedDetail>('cart:updated', { detail: { itemCount: 3, total: 59.9 } }),
);
document.addEventListener('cart:updated', (e) => {
console.log(e.detail.total.toFixed(2)); // e: CustomEvent<CartUpdatedDetail>
});
fetch and JSON. response.json() is typed as returning Promise<any>, which silently disables checking for everything that follows. Treat it as unknown and validate it (the type guards of 9.2, or a schema as in the strict mode topic):
const res = await fetch('/api/cart');
const body: unknown = await res.json(); // explicitly unknown: must be checked before use
Node.js. Node's APIs are typed by the @types/node package. Install the version that matches your Node.js major version and list it in types:
npm install --save-dev @types/node@24
import { readFile } from 'node:fs/promises';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { EventEmitter } from 'node:events';
const text = await readFile('config.json', 'utf8'); // string (Buffer without an encoding)
createServer((req: IncomingMessage, res: ServerResponse) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`You requested ${req.url ?? '/'}`);
}).listen(3000);
// Typed events: map each event name to its listener arguments
const jobs = new EventEmitter<{ done: [jobId: string, ms: number]; failed: [jobId: string, error: Error] }>();
jobs.on('done', (id, ms) => console.log(`${id} took ${ms} ms`)); // id: string, ms: number
jobs.emit('done', 'job-1', 120);
jobs.emit('done', 'job-1');
// error TS2554: Expected 3 arguments, but got 2. (the "ms" argument is missing)
Environment variables are typed as string | undefined. You can document the expected ones by augmenting NodeJS.ProcessEnv, but remember this is a claim; validate them once at startup:
export {}; // a module, so "declare global" is allowed
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL?: string;
PORT?: string;
}
}
}
const port = Number(process.env.PORT ?? 3000);
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
Mixing environments. Including both dom and @types/node in one project leads to conflicts, the classic one being timers: setTimeout returns a number in the browser and a NodeJS.Timeout in Node.js. Write ReturnType<typeof setTimeout> to stay portable, and split code for different environments (browser, server, tests) into separate tsconfig projects, each with its own lib and types.
let timer: ReturnType<typeof setTimeout> | undefined; // number or NodeJS.Timeout, depending on the environment
function debounceSave(save: () => void): void {
clearTimeout(timer);
timer = setTimeout(save, 300);
}
Third-party libraries. For any npm package there are three situations:
| Situation | How to recognise it | What to do |
|---|---|---|
| Types bundled with the package | package.json has types or a types export condition; npm shows a "TS" badge | Nothing: import just works |
| Types on DefinitelyTyped | npm shows a "DT" badge; @types/<name> exists | npm install -D @types/<name>, matching the library's major and minor version |
| No types anywhere | error TS7016: Could not find a declaration file for module '<name>' | Write declare module '<name>' { ... } for the parts you use (9.2) |
Packages with a scope map to DefinitelyTyped names with a double underscore: the types for @babel/core are published as @types/babel__core.
Example: jQuery and its plugins. jQuery (Chapter 8) is a library typed on DefinitelyTyped. Installing @types/jquery types $, the wrapped sets and the event objects. A jQuery plugin adds methods to $.fn at runtime, and you describe it by augmenting the global JQuery interface:
// npm install jquery && npm install -D @types/jquery
import $ from 'jquery';
declare global {
interface JQuery<TElement = HTMLElement> {
highlight(options?: { color?: string; durationMs?: number }): this; // returns this for chaining
}
}
$.fn.highlight = function (this: JQuery, options = {}) {
const { color = 'yellow', durationMs = 800 } = options;
return this.css('background-color', color).delay(durationMs).queue(function (next) {
$(this).css('background-color', '');
next();
});
};
$('.price').highlight({ color: '#ffe08a' }).addClass('updated'); // typed and chainable
$('.price').highlight({ colour: 'red' });
// error TS2561: Object literal may only specify known properties, but 'colour' does not exist in type
// '{ color?: string | undefined; durationMs?: number | undefined; }'. Did you mean to write 'color'?
Choosing libraries with good types. The quality of a package's types now matters as much as its API. Prefer packages that ship their own types, check that their generics produce useful inference (for example, query builders that infer result types from the query), and treat a package that needs any everywhere as a maintenance cost.
Key Takeaways
- DOM types come from
lib.dom.d.ts;querySelectoris precise for tag names, andinstanceofchecks or explicit type arguments are needed for other selectors. - Event types come from event maps, but
event.targetis onlyEventTarget | null; narrow it withinstanceofbefore use. Custom events are typed withCustomEvent<T>and by augmenting the event map. response.json()returnsany; treat the result asunknownand validate it.- Node.js is typed by
@types/node(matching the runtime version, listed intypes); usenode:imports, typedEventEmitters and validated environment variables. - Library types are bundled, on DefinitelyTyped (
@types/*), or written by you; plugins and globals are typed by augmenting existing interfaces.
Practice
- (Beginner) Build a small to-do page in TypeScript: select the form, input and list without non-null assertions, add items on submit, and toggle them with a delegated click listener on the list.
- (Intermediate) Write a Node.js script that reads a JSON file of orders with
node:fs/promises, validates it with a type guard, and emits typedprocessedandinvalidevents from a typedEventEmitter. Run it with Node.js type stripping, making sure it only uses erasable syntax.- (Interview) "You install an npm package and the compiler reports
TS7016: Could not find a declaration file for module. Walk through your options, from best to worst." Hint: check for a newer version with bundled types, then@types, then a hand-writtendeclare modulefor the parts you use, and only as a last resort an emptydeclare module.
<a id="10-the-http-protocol-and-web-communication"></a>