Phase 3 · Classes, enums and modules · Lesson 3.3
IntermediateModules and declaration files
How TypeScript handles ES modules, type-only imports, verbatimModuleSyntax and isolatedModules, module resolution, .d.ts files, declare, ambient modules, declare global, namespaces and triple-slash references.
25 min
Every real project is split across files, and some of those files aren't TypeScript at all: JavaScript libraries, CSS, images, globals injected by a script tag. This lesson covers how TypeScript connects files together, how it describes code it can't see, and the compiler flags that decide what an import turns into.
Modules vs scripts
TypeScript follows JavaScript: a file with a top-level import or export is a module, with its own scope. A file with neither is a script, and everything it declares at the top level is global.
// counter.ts: a module, `count` is private to this file
let count = 0;
export function increment() {
return ++count;
}
// main.ts
import { increment } from "./counter";
increment();The moduleDetection option can change this: "force" treats every non-declaration file as a module, which avoids accidental globals. (The default, "auto", also treats a file as a module when module is node16/nodenext and the nearest package.json has "type": "module", or when jsx is react-jsx.)
Type-only imports and exports
Types are erased, so an import that brings in only types can be deleted from the output. TypeScript lets you say that explicitly:
// user.ts
export interface User {
id: string;
name: string;
}
export function loadUser(id: string): User {
return { id, name: "Ada" };
}
// app.ts
import type { User } from "./user"; // whole import is type-only
import { loadUser, type User as U } from "./user"; // inline modifier on one name
export type { User } from "./user"; // type-only re-exportA name brought in with import type can only be used in type positions:
import type { Status } from "./status"; // Status is an enum
let s: Status; // fine: type position
if (s === Status.Active) {} // error: 'Status' cannot be used as a value
// because it was imported using 'import type'.In a single file, export type is also how you export a type alias alongside values:
type Point = { x: number; y: number };
const origin: Point = { x: 0, y: 0 };
export type { Point };
export { origin };isolatedModules and verbatimModuleSyntax
The reason type-only syntax matters: many tools compile one file at a time (esbuild, SWC, Babel, Node's type stripping). Looking at import { User } from "./user" alone, they can't tell if User is a type (drop it) or a value (keep it).
isolatedModules: true makes tsc report code that a single-file compiler can't handle safely:
- re-exporting a type without
export type(Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'); - using an ambient
const enumfrom a.d.ts; - it also implies
preserveConstEnums.
verbatimModuleSyntax: true (TS 5.0) goes further with one simple rule: what you write is what you get. Imports and exports without type are always kept; imports with type are always removed. So every type-only import must be marked, or you get 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled. It implies isolatedModules, and it's the recommended setting for new projects.
// Input, with verbatimModuleSyntax
import type { A } from "./a";
import { type B } from "./b";
import { type C, c } from "./c";
// Output
// import type: removed entirely
import {} from "./b"; // kept: the module still runs (side effects)
import { c } from "./c";Quick check
With verbatimModuleSyntax, which import is removed completely from the JavaScript output?
Module resolution in brief
moduleResolution decides how TypeScript turns "./user" or "some-lib" into a file. Two settings matter today:
| Setting | Use when | Key rules |
|---|---|---|
bundler | a bundler (Vite, webpack, esbuild) handles imports | extensionless relative imports allowed; honours package.json exports/imports |
nodenext | code runs directly in Node | follows Node's ESM/CommonJS rules; relative ESM imports need extensions |
The nodenext gotcha: you import the output file name, so in util.ts you write import { x } from "./util.js". TypeScript maps .js back to .ts during checking. Whether a file is ESM or CommonJS depends on its extension (.mts/.cts) and the nearest package.json "type". The old node setting (now called node10) predates exports and should not be used for new code.
Declaration files (.d.ts)
A .d.ts file contains only types: no function bodies, no initializers. It describes JavaScript that exists somewhere else. You meet them in three places:
lib.*.d.ts: the built-in types forArray,Promise, the DOM, chosen bylibandtarget.- Library types: bundled with a package (its
typesfield) or from DefinitelyTyped (@types/...). - Your own: generated by
tscwithdeclaration: true, or handwritten for untyped code.
// math.d.ts, generated from math.ts by `tsc --declaration`
export declare function add(a: number, b: number): number;
export declare const PI = 3.14159;
export interface Vector {
x: number;
y: number;
}skipLibCheck: true tells tsc not to type-check .d.ts files, which is faster and avoids errors in third-party types you can't fix. Your own .ts files are still fully checked.
declare: "this exists, trust me"
declare introduces a name without emitting any code. It's for values that come from outside TypeScript's view: a script tag, a bundler's define, a runtime global.
declare const __APP_VERSION__: string; // injected by the build tool
declare function track(event: string, data?: object): void;
declare class Analytics {
constructor(key: string);
send(event: string): Promise<void>;
}
track("page_view", { path: "/" });
const version = __APP_VERSION__.split(".");
// ^? const version: string[]
const client = new Analytics("public-key");
client.send("loaded");▶ Try it in the TypeScript Playground
TypeScript believes you completely. If __APP_VERSION__ isn't actually defined at runtime, you get a ReferenceError and no compile error. A declare is a promise you have to keep.
Declarations can't have implementations:
// @ts-expect-error -- Initializers are not allowed in ambient contexts.
declare const limit: number = 10;Ambient module declarations
What if you import a JavaScript package that has no types? Under strict, import confetti from "canvas-confetti" fails with Could not find a declaration file for module. You can describe the module yourself in a .d.ts with declare module "name":
// types/vendor.d.ts (a script: no top-level import/export)
declare module "legacy-charts" {
export interface ChartOptions {
width: number;
height: number;
}
export function render(el: Element, options: ChartOptions): void;
export default render;
}
// Shorthand: everything imported from it is `any`
declare module "untyped-helper";The shorthand form is a quick way to unblock a build, but every import from it is any, so you lose all checking.
Wildcard modules for assets
Bundlers let you import non-code files. A wildcard declaration types all of them at once:
// types/assets.d.ts
declare module "*.svg" {
const url: string;
export default url;
}
declare module "*.module.css" {
const classes: { readonly [className: string]: string };
export default classes;
}Then, anywhere in the app:
import logoUrl from "./logo.svg"; // string
import styles from "./card.module.css"; // { readonly [className: string]: string }TypeScript doesn't check the file exists; the bundler does. The declaration only says what such an import produces.
declare global: augmenting globals from a module
Sometimes you need to add to the global scope, for example a property a script puts on window. Inside a module, top-level declarations are local, so you wrap them in declare global:
declare global {
interface Window {
analyticsQueue: string[];
}
var FEATURE_FLAGS: Record<string, boolean>;
}
window.analyticsQueue.push("boot");
if (FEATURE_FLAGS["newCheckout"]) {
console.log("new checkout enabled");
}This works because interfaces merge: your Window declaration is added to the DOM's. Note var, not let or const: only var declarations in the global scope become properties of globalThis. declare global is only allowed in a module (or inside an ambient module declaration); that's why lesson blocks here are modules.
Quick check
globals.d.ts contains declare const API_URL: string; and works everywhere. You add import type { Config } from "./config"; at the top. What happens?
Namespaces: recognise them, prefer modules
Before ES modules existed, TypeScript had its own way to group code: namespace (originally called "internal modules", and module Foo {} is the old spelling).
namespace Geometry {
export const PI = 3.14159;
export function circleArea(r: number) {
return PI * r * r;
}
const secret = "not exported, not visible outside";
}
Geometry.circleArea(2);
// @ts-expect-error -- Property 'secret' does not exist on type 'typeof Geometry'.
Geometry.secret;A namespace compiles to an IIFE that builds an object, so it's not erasable syntax, and it doesn't tree-shake. In new code, use ES modules instead: a file is a namespace. You'll still see namespaces in two places:
- Old code and
.d.tsfiles, for exampledeclare namespace NodeJS { ... }or a library global likedeclare namespace $ { ... }. - Declaration merging: attaching types or static helpers to a function or class of the same name.
function format(value: number): string {
return value.toFixed(format.defaultDigits);
}
namespace format {
export const defaultDigits = 2;
}
format(3.14159);A type-only namespace (namespace Api { export type Id = string }) emits nothing and is fine under erasableSyntaxOnly.
Triple-slash directives: recognise them
A triple-slash directive is a comment on the first lines of a file that tells the compiler about a dependency:
/// <reference types="vite/client" /> // include an @types-style package
/// <reference path="./legacy-globals.d.ts" /> // include another file
/// <reference lib="dom.iterable" /> // include a built-in lib fileThey predate import and tsconfig.json. Today you mostly see reference types in generated files like vite-env.d.ts and in .d.ts files that depend on global types. In your own code, prefer import and the types/include options in tsconfig.json.
Spot the error
The team turned on verbatimModuleSyntax. This file now fails to compile. Why, and what's the fix?
// order.ts
import { Order, OrderStatus, createOrder } from "./models";
// Order is an interface, OrderStatus is a type alias, createOrder is a function
export function newOrder(): Order {
const status: OrderStatus = "pending";
return createOrder(status);
}Show the answer
Under verbatimModuleSyntax, an import without type is kept in the output as written. Order and OrderStatus don't exist at runtime, so the emitted import { Order, ... } would fail. TypeScript reports 'Order' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled (and the same for OrderStatus). Mark them:
import { createOrder, type Order, type OrderStatus } from "./models";Or split into import type { Order, OrderStatus } from "./models"; plus a value import. Many linters can auto-fix this (consistent-type-imports).
Recap
- A file with top-level
import/exportis a module; otherwise it's a script whose declarations are global. import type,export typeand inlinetypemodifiers mark type-only names so they can be erased.isolatedModulesflags code single-file compilers can't handle;verbatimModuleSyntaxkeeps untyped imports verbatim and requirestypeon type-only ones.moduleResolution: "bundler"for bundled apps,"nodenext"for code Node runs directly (with.jsextensions in imports)..d.tsfiles hold types only;declareintroduces names without emitting code, and TypeScript trusts it blindly.declare module "x"describes an untyped module;"*.svg"wildcards type asset imports.- In a module, use
declare globalto add globals; a top-level import turns a script.d.tsinto a module. - Namespaces and triple-slash references are legacy: recognise them, prefer ES modules and
tsconfig.
Interview cards
1 / 8