Skip to content
TR

Phase 3 · Classes, enums and modules · Lesson 3.3

Intermediate

Modules 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-export

A 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 enum from 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:

SettingUse whenKey rules
bundlera bundler (Vite, webpack, esbuild) handles importsextensionless relative imports allowed; honours package.json exports/imports
nodenextcode runs directly in Nodefollows 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 for Array, Promise, the DOM, chosen by lib and target.
  • Library types: bundled with a package (its types field) or from DefinitelyTyped (@types/...).
  • Your own: generated by tsc with declaration: 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.ts files, for example declare namespace NodeJS { ... } or a library global like declare 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 file

They 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/export is a module; otherwise it's a script whose declarations are global.
  • import type, export type and inline type modifiers mark type-only names so they can be erased.
  • isolatedModules flags code single-file compilers can't handle; verbatimModuleSyntax keeps untyped imports verbatim and requires type on type-only ones.
  • moduleResolution: "bundler" for bundled apps, "nodenext" for code Node runs directly (with .js extensions in imports).
  • .d.ts files hold types only; declare introduces 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 global to add globals; a top-level import turns a script .d.ts into a module.
  • Namespaces and triple-slash references are legacy: recognise them, prefer ES modules and tsconfig.

Interview cards

1 / 8