TypeScript NotesRohit’s interview study guide
Chapter 12

Modules & Declaration Files

How TypeScript describes code it didn't compile — .d.ts files, declare, @types, globals and module augmentation — plus namespaces.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Type-only imports and exports

An import in a TypeScript file can bring in two very different kinds of thing. Values — functions, classes, constants — exist at runtime, so the import has to survive into the JavaScript. Types — interfaces, type aliases — exist only while the compiler is checking. After type erasure there is nothing left to import, so the import has to disappear. The compiler has to decide, import by import, which is which.

By default tsc decides using import elision: it looks at how each imported name is used in the file, and if a name is only ever used in type positions, it drops it from the output. If every name in an import statement is dropped, the whole statement goes. That works when tsc compiles the whole project, because it can open ./user.ts and see that User is an interface. It does not work for tools that compile one file at a time — esbuild, swc, Babel, Vite and Node's built-in type stripping. Looking at import { User } from "./user.js" on its own, they can't tell whether User is a class (keep it) or an interface (delete it).

import type solves this by labelling the import. Think of the types as the paperwork that travels with a delivery: the compiler reads it, checks everything against it, and then shreds it before the truck (the JavaScript) leaves. import type stamps "paperwork" on the box, so any tool can shred it without opening it.

TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
import { createServer, type Server } from "node:http";

function handle(req: IncomingMessage, res: ServerResponse): void {
  res.end(`You asked for ${req.url}`);
}

const server: Server = createServer(handle);
What’s happening
  1. Line 1 is a type-only import declaration. IncomingMessage and ServerResponse are actually classes in Node, but this file only uses them to annotate req and res, so their types are all it needs. The whole statement is removed from the output, whatever compiler settings you use.
  2. Line 2 is a normal import with an inline type modifier on one name. createServer is a value — it's called at runtime — so it stays. type Server is dropped. The emitted line is import { createServer } from "node:http";.
  3. Checking is unaffected: req.url is still string | undefined, res.end still wants a string, and createServer(handle) still checks that handle has the right request-listener signature. type changes what's emitted, never what's checked.
  4. Because every type-only name is labelled, a single-file tool can strip them without knowing anything about node:http. This file is safe to run through esbuild or Vite, or directly with node server.ts.
AdvancedType-only imports lose value meaning

The flip side: a name imported with type brings only its type meaning. A class name has two meanings — the instance type and the constructor value — and import type gives you the first one only.

TypeScript
import type { Server } from "node:http";

let s: Server;                     // fine: a type annotation
type ServerClass = typeof Server;  // fine: a type query is still a type position
const created = new Server();      // ❌ TS1361: 'Server' cannot be used as a value because it was imported using 'import type'.
What’s happening
  1. let s: Server uses Server as the instance type → allowed.
  2. typeof Server in a type alias asks for the type of the constructor value. That sounds like a value, but the query is answered at compile time and erased, so TypeScript allows it under import type.
  3. new Server() needs the real constructor at runtime, and the import that would provide it is guaranteed to be deleted. The compiler stops you: TS1361 "'Server' cannot be used as a value because it was imported using 'import type'".
  4. Fix: import it as a value (import { Server } from "node:http"). Nothing is lost — a value import of a class still gives you its type too.
AdvancedisolatedModules and verbatimModuleSyntax

What actually gets emitted

Two settings decide how strict the compiler is about this:

  • isolatedModules — reports code that a single-file compiler can't handle correctly. The classic case is re-exporting a type without type: export { User } from "./user.js" fails with TS1205 "Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'", because a single-file tool can't know whether to keep that re-export.
  • verbatimModuleSyntax (TypeScript 5.0+) — replaces the guessing with one simple rule: imports without type are kept exactly as written; anything marked type is deleted. Import elision is switched off. An import of a type without the type keyword is an error: TS1484 "'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled."

The difference shows up when the imported module has a side effect:

user.ts
export interface User { id: number; name: string }
export type Role = "admin" | "user";

console.log("user.ts loaded"); // a side effect at the top level
app.ts
import type { User } from "./user.js";
import { type Role } from "./user.js";

const u: User = { id: 1, name: "Ann" };
const r: Role = "admin";
console.log(u.name, r);

Default (import elision) — app.js

JavaScript
const u = { id: 1, name: "Ann" };
const r = "admin";
console.log(u.name, r);
export {};

verbatimModuleSyntax: true — app.js

JavaScript
import {} from "./user.js";
const u = { id: 1, name: "Ann" };
const r = "admin";
console.log(u.name, r);
What’s happening
  1. import type { User } is deleted in both modes — a type-only import declaration always disappears, including its module specifier. Nothing about ./user.js survives from that line.
  2. import { type Role } is a normal import statement whose only name is marked type. With import elision, the compiler removes Role, sees the braces are now empty, and deletes the statement too. It adds export {} so the output is still an ES module.
  3. With verbatimModuleSyntax, only the type Role name is removed; the statement is kept as written, leaving import {} from "./user.js". That still loads user.js.
  4. Run them: the default output prints Ann admin; the verbatim output prints user.ts loaded first, then Ann admin. If user.ts registered a plugin or a polyfill, the default build would silently skip it.
  5. The rule to remember: import type { X } removes the whole import; import { type X } removes only the name. If you want neither side effect nor import, use import type. If you want to guarantee a module runs, write a plain side-effect import: import "./polyfills.js" is never removed in either mode.

Exports work the same way: export type { User } and export type { User } from "./user.js" are always removed, and TypeScript 5.0 added export type * from "./types.js" for re-exporting every type from a module.

#Declaration files (.d.ts)

From my notes: .d.ts files are purely type definition files that describe the types and signatures of objects, functions and classes without providing the implementation. They let TypeScript understand the shape of a module implemented in JavaScript — a contract between your TypeScript code and existing JavaScript.

The mental model is a C header file, or the menu in a restaurant: the menu lists every dish and what it comes with, but there's no food on it. The compiler reads only the menu; the kitchen (the .js file) is what actually runs. A .d.ts file can contain types, interfaces and signatures — but no function bodies, no initial values, no statements. It never produces any JavaScript.

You meet .d.ts files in three ways: libraries ship them next to their compiled .js (generated by tsc --declaration), @types/* packages provide them for libraries that don't (see @types packages), and you write them yourself for JavaScript you can't or don't want to convert.

Here is the example from my notes — a plain CommonJS JavaScript module:

utils.js
function add(a, b) {
  return a + b;
}
function multiply(a, b) {
  return a * b;
}
module.exports = { add, multiply };
AdvancedA matching utils.d.ts next to the JS

The correct declaration file for it, saved next to it as utils.d.ts:

utils.d.ts
export declare function add(a: number, b: number): number;
export declare function multiply(a: number, b: number): number;
AdvancedUsing the declarations from main.ts
main.ts
import { add, multiply } from "./utils.js";

console.log(add(2, 3), multiply(2, 3)); // 5 6
add("2", 3); // ❌ TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
What’s happening
  1. The compiler reaches import … from "./utils.js" in main.ts and starts module resolution. For a relative path ending in .js, it looks for the TypeScript equivalents first: utils.ts, then utils.tsx, then utils.d.ts. It finds utils.d.ts and stops — it never reads utils.js (unless allowJs is on and there's no declaration).
  2. utils.d.ts has top-level export statements, so it is a module: its exports are the exports of ./utils.js. export declare function add(a: number, b: number): number says "the module has an export named add with this signature; the implementation lives elsewhere".
  3. With that contract, add(2, 3) type-checks and add("2", 3) fails with TS2345. The .js file has no types, so this check only exists because of the .d.ts.
  4. At runtime the .d.ts plays no part. The emitted main.js contains require("./utils.js") (or import, for ESM), Node loads utils.js, and prints 5 6.
  5. Nothing checks that the contract is true. If utils.js later renames multiply to times, the .d.ts still says multiply, the program still compiles, and it crashes at runtime. That's the cost of hand-written declarations — keep them next to the code they describe.
AdvancedThe declare module version from my notes

My notes wrote the same file differently:

utils.d.ts (from my notes)
declare module "utils" {
  export function add(a: number, b: number): number;
  export function multiply(a: number, b: number): number;
}
What’s happening
  1. This file has no top-level import or export, so it's a script, not a module. Its declare module "utils" { … } is an ambient module declaration: it says "somewhere there is a module whose import name is the bare string "utils"".
  2. import { add } from "./utils.js" resolves to this file, but the file itself has no exports, so the compiler reports TS2306 "File '…/utils.d.ts' is not a module."
  3. import { add } from "utils" (bare name) does compile, because the ambient declaration matches the string "utils" — as long as the file is included in the project. But at runtime Node looks for a package called utils in node_modules, not the file next to you, and fails with Error: Cannot find module 'utils'.
  4. So the two forms answer different questions. A sibling .d.ts with top-level export describes this file next to me. declare module "name" describes a package by its import name — the right tool for an untyped npm package (see Typing an untyped package with declare module).
AdvancedCorrecting my notes: declare module for local files
AdvancedGenerating .d.ts from TypeScript

When you write a library in TypeScript, you don't write .d.ts files by hand — "declaration": true makes tsc emit one next to every .js it outputs.

math.ts
export function add(a: number, b: number) {
  return a + b;
}
export const PI = 3.14159;
export interface Point { x: number; y: number }
export class Vec {
  #hidden = 1;
  constructor(public x: number, private secret = 0) {}
  len() { return Math.abs(this.x); }
}
const internal = "not exported";
math.d.ts (emitted)
export declare function add(a: number, b: number): number;
export declare const PI = 3.14159;
export interface Point {
    x: number;
    y: number;
}
export declare class Vec {
    #private;
    x: number;
    private secret;
    constructor(x: number, secret?: number);
    len(): number;
}
What’s happening
  1. add had no return-type annotation; the compiler inferred number and writes the inferred type out in the declaration. Consumers never re-infer anything from your source.
  2. Function bodies, the const initializer expression and the class body are dropped. PI keeps = 3.14159 because a const with a literal value is allowed in a declaration — it records the literal type, not code to run.
  3. Point is copied as-is: interfaces are already pure types. internal isn't exported, so it isn't part of the contract and doesn't appear.
  4. The class keeps its shape but not its secrets: private secret; is listed without a type (other code only needs to know the name is taken), and every #private field collapses into one #private; marker. That marker is there so the class stays nominal — another class with the same public members isn't assignable to Vec.
  5. The parameter property public x becomes a normal property declaration plus a constructor parameter, and the default value secret = 0 becomes an optional parameter secret?: number.

#declare and ambient declarations

The declare keyword says: "this exists at runtime — someone else created it — here is its type. Emit nothing." A declaration with declare is called an ambient declaration: it describes something without implementing it. Everything in a .d.ts file is ambient automatically; in a .ts file you add declare by hand.

You need it whenever a value appears at runtime without your TypeScript code creating it: a global set by a <script> tag (Google Analytics' gtag), a constant your bundler replaces at build time (Vite's define, webpack's DefinePlugin), or a variable injected by the host environment. Without a declaration, the compiler only sees a name that was never defined.

TypeScript
declare const __APP_VERSION__: string; // replaced by the bundler at build time
declare function gtag(command: "event", name: string, params?: Record<string, unknown>): void;
declare class Analytics {
  constructor(writeKey: string);
  track(event: string, props?: Record<string, unknown>): void;
}

console.log(`v${__APP_VERSION__}`);
gtag("event", "signup", { plan: "pro" });
new Analytics("wk_123").track("page_view");
What’s happening
  1. declare const __APP_VERSION__: string adds a name and a type to the compiler's scope. There's no initializer — the value comes from outside. The compiled JavaScript has no line for it.
  2. declare function gtag(…) is a signature without a body. Calls are checked against it: gtag("click", "x") would fail because the first parameter only accepts "event".
  3. declare class Analytics describes a constructor and its instances: constructor parameters, methods, no bodies. It can be used with new and as a type, exactly like a real class.
  4. The emitted JavaScript is just the last three lines. They run correctly only if, at runtime, __APP_VERSION__ has been substituted and gtag and Analytics exist on the page. If not, you get ReferenceError: gtag is not defined — declare is a promise the compiler can't verify.
  5. In this snippet the declarations are local to the module. In a real project you put them in a global .d.ts file (no top-level import/export, see Global declarations and extending window) so every file can see them.
AdvancedAmbient declarations can't contain code

Because an ambient declaration only describes, it can't contain anything that would produce code:

TypeScript
declare let retries: number = 3;         // ❌ TS1039: Initializers are not allowed in ambient contexts.
declare function reset(): void {}        // ❌ TS1183: An implementation cannot be declared in ambient contexts.
declare const MAX_RETRIES = 3;           // allowed: a const with a literal records the literal type 3
What’s happening
  1. = 3 on a let is runtime code (an assignment), so it's rejected with TS1039.
  2. A function body is runtime code, so it's rejected with TS1183. Drop the braces and end the signature with ;.
  3. declare const MAX_RETRIES = 3 is the one exception: a const with a literal initializer is how a declaration records a literal type. It means "a constant whose type is 3" — you saw tsc generate exactly this for PI in Declaration files (.d.ts).
AdvancedThe declare family at a glance

The declare family at a glance:

FormDeclaresTypical place
declare const/let/var x: Ta variable that exists at runtimeglobal .d.ts, top of a file
declare function f(…): Ta function (overloads allowed)global .d.ts, package declarations
declare class C { … }a class: constructor, members, no bodiespackage declarations
declare enum E { … }an enum object that exists elsewhere (see Ambient enums)package declarations
declare namespace N { … }an object with nested members, or a group of types.d.ts for globals like NodeJS, JSX
declare module "name" { … }a module by import name, or an augmentationvendor.d.ts, express.d.ts
declare global { … }additions to the global scope, from inside a moduleglobals.d.ts with export {}

Interfaces and type aliases never need declare — they're types already and never emit anything.

#@types packages

From my notes: many popular libraries already have type definitions available via @types. For example, to use lodash: npm install lodash @types/lodash.

Some libraries are written in TypeScript or ship hand-written declarations, and their package.json points at them ("types", or a "types" condition in "exports"). Many older libraries are plain JavaScript and ship no types at all. For those, the community maintains DefinitelyTyped, a huge GitHub repository of declaration files, and publishes each one to npm under the @types scope: the types for lodash are @types/lodash. Scoped packages get a double underscore: @babel/core → @types/babel__core.

The mental model: the library is the product, @types/x is the third-party manual. It's written by different people, versioned separately, and can drift. By convention its major and minor version follow the library's (@types/lodash@4.17.x describes lodash@4.17), so match them when you install.

app.ts — needs lodash@4 + @types/lodash@4.17
import { chunk, debounce } from "lodash";

const pages = chunk([1, 2, 3, 4, 5], 2); // number[][] → [[1, 2], [3, 4], [5]]
const save = debounce((text: string) => console.log("saving", text), 300);
save("draft");
What’s happening
  1. The compiler resolves "lodash" like Node would: it finds node_modules/lodash/package.json. lodash has no "types" field and no .d.ts files, so there's nothing to read there.
  2. It then tries node_modules/@types/lodash, finds its package.json with "types": "index.d.ts", and uses that file as the module's types. --traceResolution shows each step, ending with "Module name 'lodash' was successfully resolved to '…/@types/lodash/index.d.ts'".
  3. chunk is declared as chunk<T>(array: List<T> | null | undefined, size?: number): T[][], so T is inferred as number and pages is number[][].
  4. At runtime, @types/lodash does nothing — it contains only .d.ts files. Node loads node_modules/lodash/lodash.js. That's why @types/* packages go in devDependencies for an application.
  5. If neither the package nor @types provides types, the import fails under strict with TS7016 — see Typing an untyped package with declare module.
AdvancedGlobal @types and the types option

Globals from @types: the types option

Module imports find @types packages on demand. Global declarations are different: @types/node declares process, Buffer and the "node:fs" module, and you never import @types/node itself, so the compiler has to be told to load it. That's what compilerOptions.types is for.

tsconfig.json
{
  "compilerOptions": {
    "types": ["node"]
  }
}
What’s happening
  1. "types": ["node"] means "load node_modules/@types/node into every compilation as global declarations". Its ambient declarations — process, Buffer, declare module "node:fs" and the rest — become visible everywhere.
  2. Packages not listed are not loaded globally, but they still work for imports. An import { chunk } from "lodash" finds @types/lodash through module resolution whatever types says.
  3. Since TypeScript 6.0, types defaults to []. Before that, the default was "every package in node_modules/@types", which quietly pulled in globals from packages you never meant to use (two test frameworks both declaring a global describe, for example). With TypeScript 7.0.2 and no types setting, console.log(process.env.HOME) fails with TS2591 "Cannot find name 'process'. Do you need to install type definitions for node? Try npm i --save-dev @types/node and then add 'node' to the types field in your tsconfig." — even with @types/node installed.
  4. "types": ["*"] restores the old "load everything" behaviour. "typeRoots" changes the folders searched for these names (default: node_modules/@types up the directory tree); you rarely need it.

#Typing an untyped package with declare module

Sometimes a package has no types anywhere — no "types" field, no @types package. Under strict (specifically noImplicitAny), importing it is an error:

Output
error TS7016: Could not find a declaration file for module 'slugify-lite'.
'/app/node_modules/slugify-lite/index.js' implicitly has an 'any' type.
AdvancedShorthand declare module for any import

The compiler found the JavaScript but has nothing to tell it what the exports look like, and it refuses to quietly use any. You have three options, from least to most effort: switch the import off with a shorthand declaration, write a real declaration, or contribute one to DefinitelyTyped.

All of them go in a .d.ts file that your tsconfig.json includes — conventionally something like src/types/vendor.d.ts. The file must be a script (no top-level import or export), so its declare module statements are ambient declarations.

src/types/vendor.d.ts
declare module "legacy-charts";
AdvancedShorthand module imports become any
TypeScript
import { drawChart } from "legacy-charts";

drawChart("#sales", { anything: "goes" }); // compiles: drawChart is any
What’s happening
  1. declare module "legacy-charts"; with no body is a shorthand ambient module. It tells the compiler "a module with this exact import name exists" and nothing else.
  2. Every import from a shorthand module has type any. drawChart is any, so any call with any arguments compiles, and its result is any too.
  3. The TS7016 error goes away because the compiler now has a declaration for the name. The real node_modules/legacy-charts is still what Node loads at runtime.
  4. Use this to unblock yourself, not as the final state — it is any with a nicer name. Every mistake in how you call the library is invisible.
AdvancedDeclaring only the API you use

A real declaration describes only what you use. You don't have to type the whole library on day one:

src/types/slugify-lite.d.ts
declare module "slugify-lite" {
  export interface SlugifyOptions {
    lower?: boolean;
    separator?: string;
  }
  export function slugify(input: string, options?: SlugifyOptions): string;
}
AdvancedUsing the typed slugify-lite module
TypeScript
import { slugify, type SlugifyOptions } from "slugify-lite";

const options: SlugifyOptions = { lower: true, separator: "_" };
const id: string = slugify("Hello World", options);
slugify(42); // ❌ TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
What’s happening
  1. The compiler resolves "slugify-lite": it finds the package's JavaScript but no types, then checks the ambient modules it knows about and finds declare module "slugify-lite" from the .d.ts. The block's exports become the module's exports.
  2. Inside the block, export interface and export function work like they do in a module file. You don't write declare on each member — everything inside declare module is already ambient.
  3. slugify("Hello World", options) matches the signature and returns string. slugify(42) fails with TS2345 — a check that the shorthand version could never give you.
  4. Importing a name you didn't declare (say import { unslugify } from "slugify-lite") now fails with TS2305 "Module '"slugify-lite"' has no exported member 'unslugify'". Add members as you start using them.
AdvancedDeclaring a CommonJS function export

Older CommonJS packages often export a single function (module.exports = padLeft) that also carries properties. The declaration for that uses export =, plus a namespace merged with the function for the extra members:

src/types/pad-left-lite.d.ts
declare module "pad-left-lite" {
  function padLeft(input: string, width: number, fill?: string): string;
  namespace padLeft {
    const version: string;
  }
  export = padLeft;
}
AdvancedCalling the typed pad-left-lite export
TypeScript
import padLeft from "pad-left-lite";

padLeft("7", 3, "0");                // "007"
padLeft.version.toUpperCase();
What’s happening
  1. function padLeft and namespace padLeft share a name, so they merge into one symbol: a callable function that also has a version property — exactly the shape of a CommonJS function with properties attached.
  2. export = padLeft says "the whole module.exports is this value", which is how CommonJS works. ES-style export function would instead describe exports.padLeft, a property — the wrong shape.
  3. import padLeft from "pad-left-lite" is a default import of an export = module. That works because esModuleInterop treats module.exports as the default export. Bundlers do the same, and TypeScript 7 no longer lets you turn it off: "esModuleInterop": false is error TS5108 "Option 'esModuleInterop=false' has been removed."
  4. Both uses are checked: the call against the function signature, padLeft.version against the namespace.
AdvancedWildcard declarations for assets

Wildcard declarations cover non-code imports your bundler understands — images, CSS modules, raw text:

src/types/assets.d.ts
declare module "*.svg" {
  const url: string;
  export default url;
}
AdvancedImporting an SVG as a URL string
TypeScript
import logoUrl from "./logo.svg";

const img = document.createElement("img");
img.src = logoUrl; // string
What’s happening
  1. "*.svg" is a pattern: any import specifier ending in .svg, relative or not, matches it. * can appear once.
  2. The compiler never tries to read logo.svg. It matches the specifier against the pattern and uses the block as the module's type: a default export of type string.
  3. That matches what Vite or webpack actually do: the import becomes the asset's URL at build time. If your bundler turns SVGs into React components instead, the declaration must say so — the compiler can't check this against the bundler's real behaviour.
  4. Frameworks ship these for you: Vite's vite/client types declare *.svg, *.css, *.module.css and many more, which is why a Vite project's tsconfig lists "types": ["vite/client"].

#Global declarations and extending window

The global scope in TypeScript is made of every script file in the program — every file with no top-level import or export — plus the built-in lib files (lib.dom.d.ts declares interface Window, document, and so on). A declaration at the top level of a script file is global. A declaration in a module file is local to that module.

That creates a puzzle. You often want to add a global — a property on window set by an analytics script, or a config object injected into the page — from a file that is a module, because it imports a type. In a module, a top-level interface Window { … } declares a new, local Window that has nothing to do with the global one. declare global { … } is the way out: it reaches from inside a module back into the global scope. Everything inside it is merged into the globals, using declaration merging.

globals.d.ts
export {}; // makes this file a module — required for `declare global`

declare global {
  interface Window {
    analytics?: { track(event: string, props?: Record<string, unknown>): void };
  }
  var __APP_CONFIG__: { apiUrl: string; release: string };
  let buildNumber: number;
}
AdvancedUsing the augmented globals
TypeScript
window.analytics?.track("page_view");

console.log(__APP_CONFIG__.apiUrl);
console.log(window.__APP_CONFIG__.release);
console.log(globalThis.buildNumber); // ❌ TS2339: Property 'buildNumber' does not exist on type 'typeof globalThis'.
What’s happening
  1. export {} exports nothing, but its presence makes globals.d.ts a module. Without it, the compiler rejects the file: TS2669 "Augmentations for the global scope can only be directly nested in external modules or ambient module declarations."
  2. interface Window { analytics?: … } inside declare global merges with the Window interface from lib.dom.d.ts. Window now has every DOM member plus analytics. Making it optional (?) is honest — the script may be blocked by an ad blocker — so the call needs ?..
  3. var __APP_CONFIG__ declares a global variable. A global var also becomes a property of the global object, so it's visible as a bare name, as window.__APP_CONFIG__ and as globalThis.__APP_CONFIG__.
  4. let buildNumber is a global too — buildNumber works as a bare name — but, exactly like JavaScript, a top-level let or const does not create a property on the global object. So globalThis.buildNumber fails with TS2339. Use var in declare global when the value lives on window.
  5. None of this makes the values exist. Something at runtime — a <script> tag, the server's HTML template — has to set them; the declaration only describes them.
AdvancedErrors from a missing export {}

If you forget the export {} and the file has no other import or export, you get both errors at once:

Output
globals.d.ts(1,9): error TS2669: Augmentations for the global scope can only be directly nested in external modules or ambient module declarations.
main.ts(1,8): error TS2339: Property 'analytics' does not exist on type 'Window & typeof globalThis'.
AdvancedScript-file style global declarations

There's an older, equally valid style: put the declarations at the top level of a script .d.ts file, with no declare global and no export.

window.d.ts — a script file (no import/export)
interface Window {
  buildId: string;
}
declare const BUILD_TIME: string;
What’s happening
  1. This file has no top-level import or export, so it's a script and everything in it is already global. interface Window merges with the DOM's Window directly.
  2. declare const BUILD_TIME is a global constant for the whole program.
  3. The catch: the moment you add import type { User } from "./user" to this file (say, to type a property), it becomes a module, every declaration in it turns local, and the globals silently vanish. That's why declare global + export {} is the safer habit.
  4. moduleDetection: "force" (which treats every non-declaration file as a module) doesn't change this: .d.ts files are still classified by whether they have a top-level import or export.
AdvancedPitfall: global .d.ts outside the program

#Module augmentation

Module augmentation adds declarations to a module that already has types — a package's own types, @types/*, or one of your own modules. You re-open the module with declare module "name" { … } from a file that is itself a module, and the compiler merges what you write into the existing declarations. It's declaration merging across files: interfaces from both places combine into one.

The typical reason is that a library has an extension point the library author couldn't know how you'd use. Express middleware attaches req.user; a React app uses CSS custom properties in style; a plugin adds a method to a library's class. The library's types are correct, just incomplete for your app.

Here is a real one that works with @types/react 19. By default, style doesn't accept CSS custom properties: style={{ "--accent": accent }} fails with TS2353 "Object literal may only specify known properties, and '"--accent"' does not exist in type 'Properties<string | number, string & {}>'."

src/types/react-css-vars.d.ts
import "react";

declare module "react" {
  interface CSSProperties {
    [customProperty: `--${string}`]: string | number;
  }
}
AdvancedCSS custom properties after augmentation
React + TypeScript
export function Badge({ accent }: { accent: string }) {
  return <span style={{ "--accent": accent, color: "var(--accent)" }}>New</span>;
}
What’s happening
  1. import "react" makes this file a module. That single line changes the meaning of the next statement: inside a module, declare module "react" is an augmentation of the existing "react" module, not a new declaration.
  2. The compiler resolves "react" to @types/react/index.d.ts and looks for CSSProperties there. It's an interface, so your interface CSSProperties { … } merges with it: the result has every standard CSS property plus your new index signature.
  3. [customProperty: `--${string}`]: string | number is an index signature with a template literal key: any property whose name starts with -- is allowed, with a string or number value (see Template literal types).
  4. In Badge, "--accent" matches the index signature and color matches the original color property, so the object literal is accepted. The excess-property error is gone in every file of the program, because augmentation changes the module's types globally.
  5. If you misspell the module name, or the package isn't installed, the compiler can't find anything to augment. In a .ts file that's TS2664 "Invalid module name in augmentation, module '…' cannot be found."
AdvancedAdding req.user in Express

The most-asked example. Express types (@types/express 5.x, with @types/express-serve-static-core 5.x) declare an empty global Express.Request interface that their Request extends — an extension point built exactly for this:

src/types/express.d.ts — matches @types/express 5.x
import type { Role } from "../auth/roles.js";

declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: Role };
    }
  }
}
src/routes/me.ts
import express from "express";
const router = express.Router();

router.get("/me", (req, res) => {
  if (!req.user) {
    res.status(401).json({ error: "Not signed in" });
    return;
  }
  res.json({ id: req.user.id, role: req.user.role });
});
What’s happening
  1. import type { Role } … serves two purposes: it brings in a type, and it makes the file a module, so declare global is allowed.
  2. @types/express-serve-static-core contains declare global { namespace Express { interface Request {} } } and declares its own Request as interface Request<…> extends http.IncomingMessage, Express.Request. The empty global interface is a deliberate hook.
  3. Your interface Request inside namespace Express merges into that hook, so every Express Request — in every route, every middleware — now has an optional user.
  4. In the route, req is contextually typed as Express's Request, so req.user is { id: string; role: Role } | undefined. The if (!req.user) check narrows it, and after the return, req.user.id is safe.
  5. Keep user optional. The type is shared by every route, including ones that run before your auth middleware; declaring it as required would be a lie the compiler can't catch. Re-opening declare module "express-serve-static-core" { interface Request { … } } works too — it's the same interface.

#Ambient modules vs module augmentation

Both use the same syntax — declare module "name" { … } — and do opposite things. Which one you get depends on one fact about the file it's written in: is the file a module?

Ambient module declarationModule augmentation
Written ina script file (no top-level import/export)a module file (at least one top-level import/export)
Means"here is the complete type of module name""add these declarations to the existing module name"
Needs existing types?No — it is the typesYes — the module must resolve, or TS2664
Typical useuntyped packages, *.svg wildcards, the shorthand declare module "x";req.user on Express, CSSProperties, plugin methods
Can it remove or change members?It defines everythingNo — it can only add; interfaces merge

A way to remember it: a script file is talking about the world, so declare module introduces a module to the world. A module file is talking about itself and its imports, so declare module patches one of its imports.

Which one is it?
src/types/router.d.ts
declare module "tiny-router" {
  interface RouteMeta {
    requiresAuth?: boolean;
  }
}

tiny-router ships its own types, including RouteMeta, createRouter and navigate. After adding this file, what happens to import { createRouter } from "tiny-router"?

Show answer

It breaks, with TS2305 "Module '"tiny-router"' has no exported member 'createRouter'".

What’s happening
  1. The file has no top-level import or export, so it's a script. In a script, declare module "tiny-router" is an ambient module declaration — a complete description of the module.
  2. Ambient declarations for a module name take precedence over the package's own types, so the compiler now believes tiny-router exports exactly one thing: the RouteMeta interface.
  3. createRouter and navigate are missing from that description, so every file importing them fails with TS2305.
  4. The fix is one line at the top: import "tiny-router"; or export {};. The file becomes a module, declare module "tiny-router" becomes an augmentation, and RouteMeta merges into the real interface while everything else stays intact.

#Generating declarations from JSDoc

Advanced

From my notes: JSDoc can generate declarations too. If a library is written in JavaScript with JSDoc type comments, the compiler can read those comments and produce real .d.ts files — so you get TypeScript consumers without converting the source. This is how some well-known projects (Svelte, for example) ship types for JavaScript source.

It builds on Type-checking JavaScript with JSDoc: with allowJs, the compiler reads .js files; JSDoc @param, @returns, @typedef and @type give it the types. Turn on declaration and it writes what it understood into .d.ts files.

src/cart.js
/**
 * @typedef {object} CartItem
 * @property {string} sku
 * @property {number} price  Unit price in cents
 * @property {number} [qty]  Defaults to 1
 */

/**
 * Total price of a cart in cents.
 * @param {CartItem[]} items
 * @param {{ discount?: number }} [options]
 * @returns {number}
 */
export function total(items, options = {}) {
  const sum = items.reduce((acc, item) => acc + item.price * (item.qty ?? 1), 0);
  return sum - (options.discount ?? 0);
}

export class Cart {
  /** @type {CartItem[]} */
  items = [];

  /** @param {CartItem} item */
  add(item) {
    this.items.push(item);
    return this;
  }
}
tsconfig.json
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "types",
    "rootDir": "src",
    "module": "nodenext",
    "target": "esnext",
    "strict": true
  },
  "include": ["src"]
}
types/cart.d.ts (generated)
/**
 * @typedef {object} CartItem
 * @property {string} sku
 * @property {number} price  Unit price in cents
 * @property {number} [qty]  Defaults to 1
 */
export type CartItem = {
    sku: string;
    /**
     * Unit price in cents
     */
    price: number;
    /**
     * Defaults to 1
     */
    qty?: number;
};
/**
 * Total price of a cart in cents.
 * @param {CartItem[]} items
 * @param {{ discount?: number }} [options]
 * @returns {number}
 */
export declare function total(items: CartItem[], options?: {
    discount?: number;
}): number;
export declare class Cart {
    /** @type {CartItem[]} */
    items: CartItem[];
    /** @param {CartItem} item */
    add(item: CartItem): this;
}
What’s happening
  1. allowJs puts src/cart.js into the program. checkJs also type-checks it: without the ?? 1, item.price * item.qty is reported as TS18048 "'item.qty' is possibly 'undefined'", because [qty] made it optional.
  2. @typedef {object} CartItem with @property lines becomes export type CartItem = { … }. [qty] in square brackets means optional, so it becomes qty?: number, and the text after each property becomes a doc comment that editors show on hover.
  3. @param {CartItem[]} items and [options] become a typed, optional parameter; @returns {number} becomes the return type. The default value = {} is gone — defaults are implementation, not contract.
  4. return this in add is inferred as the polymorphic this type, so a subclass's add returns the subclass. Nobody wrote that in JSDoc; the compiler inferred it and wrote it out.
  5. emitDeclarationOnly means tsc writes only the .d.ts files into types/ — the .js files are already the runtime code and are published as they are. Point package.json at the output with "types": "./types/cart.d.ts" (or a "types" condition in "exports"), and TypeScript users get full checking for a JavaScript library.

#Namespaces

From my notes: namespaces were used before ES6 to keep related code together. Before JavaScript had modules, every script shared one global scope. TypeScript's answer (originally called "internal modules") was the namespace: a named block whose exported members become properties of one global object, while everything else stays private inside it — the JavaScript IIFE module pattern, with compiler support.

TypeScript
namespace Utils {
  export function add(a: number, b: number): number {
    return a + b;
  }
  export function subtract(a: number, b: number): number {
    return a - b;
  }
  const secret = 42; // not exported → private to the namespace
}

console.log(Utils.add(10, 5)); // 15
console.log(Utils.subtract(10, 5)); // 5
AdvancedEmitted JavaScript for a namespace
Emitted JavaScript
var Utils;
(function (Utils) {
    function add(a, b) {
        return a + b;
    }
    Utils.add = add;
    function subtract(a, b) {
        return a - b;
    }
    Utils.subtract = subtract;
    const secret = 42;
})(Utils || (Utils = {}));
console.log(Utils.add(10, 5));
console.log(Utils.subtract(10, 5));
What’s happening
  1. Unlike most TypeScript syntax, a namespace with values in it is not erased — it compiles to real code. var Utils; declares the object's variable.
  2. The body becomes an IIFE called with Utils || (Utils = {}): use the existing object if one exists, otherwise create an empty one. That expression is what lets several namespace Utils blocks — even in different script files — add to the same object.
  3. Inside, each export function becomes a local function plus an assignment Utils.add = add. secret isn't exported, so it stays a local variable of the IIFE — private, exactly like the module pattern.
  4. Utils.add(10, 5) is a property lookup on that object → 15; Utils.subtract(10, 5) → 5.
  5. Because a namespace emits code, it's not "erasable syntax": Node's type stripping rejects it with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript namespace declaration is not supported in strip-only mode, and erasableSyntaxOnly reports TS1294 "This syntax is not allowed when 'erasableSyntaxOnly' is enabled". A declare namespace (types only) is fine in both.
AdvancedNamespaces merged with functions

Namespaces merge with each other and with functions, classes and enums of the same name. That's still useful for types, even in modern code:

TypeScript
function formatPrice(cents: number, options?: formatPrice.Options): string {
  const currency = options?.currency ?? "CAD";
  return `${(cents / 100).toFixed(2)} ${currency}`;
}
namespace formatPrice {
  export interface Options {
    currency?: "CAD" | "USD" | "EUR";
  }
}

const opts: formatPrice.Options = { currency: "USD" };
formatPrice(1999, opts); // "19.99 USD"

module Legacy { export const x = 1; } // ❌ TS1540: A 'namespace' declaration should not be declared using the 'module' keyword. Please use the 'namespace' keyword instead.
What’s happening
  1. function formatPrice and namespace formatPrice merge into one symbol. Because the namespace contains only an interface, it emits no code — it's a purely type-level namespace, so this part is erasable.
  2. formatPrice.Options reads like a property but it's a type reached through the namespace. Callers import one name and get both the function and its options type, which is the pattern many .d.ts files use (see padLeft.version in Typing an untyped package with declare module).
  3. formatPrice(1999, opts) → 1999 / 100 is 19.99 → "19.99 USD".
  4. The last line uses the old spelling, module Legacy { }, from when namespaces were "internal modules". Current TypeScript (7.0) rejects it with TS1540; write namespace. (declare module "quoted-name" is a different thing and is still fine.)

#Namespaces vs modules

They solve the same problem — keeping code organised without polluting the global scope — in two different eras. A namespace is a TypeScript feature that builds one global object at runtime. A module is a JavaScript feature: each file is its own scope, and you connect files with import/export. Modules won, and the TypeScript handbook itself says to prefer them for new code.

NamespacesES modules
Scopemembers hang off one global object (Utils.add)each file has its own scope
Dependenciesimplicit: file order, /// <reference path>explicit: import statements
Runtimea TypeScript-only construct compiled to an IIFEnative in browsers and Node
Tree shakingno — the whole object is keptyes — bundlers drop unused exports
Single-file compilers / Node type strippingvalue namespaces not supportedfully supported
Still used fortypes in .d.ts files: namespace NodeJS, namespace Express, namespace React.JSX; UMD globalseverything else

The rows that matter most in practice:

  1. Dependencies: with namespaces, file b.ts can use Utils.add from a.ts only if a.ts runs first, and nothing in b.ts says so. With modules, import { add } from "./a.js" states the dependency, and the loader orders everything.
  2. Bundling: Utils is one object, so a bundler can't tell which members you use and keeps them all. Separate named exports can be dropped individually.
  3. Tooling: Node's type stripping rejects value namespaces outright, and single-file compilers like esbuild and swc can't merge a namespace that is split across files — the reasons erasableSyntaxOnly exists.
  4. Where namespaces are still right: in declaration files, to group types or describe a global object. @types/node puts ProcessEnv in namespace NodeJS; Express's extension point is namespace Express; React's JSX types live in namespace React.JSX. These are declare namespaces — types only, nothing emitted.
  5. A UMD library's .d.ts ends with export as namespace MyLib;, which says "when this file is loaded with a plain <script> tag, the same exports are available as the global MyLib". That is the one bridge between the two worlds.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.