Modules

Real projects are split across many files. TypeScript uses standard JavaScript modules — import and export — and adds a few type-specific extras, such as importing something purely as a type so it disappears completely from the compiled output.

What makes a file a module

Any file containing a top-level import or export is a module: its variables, functions and types are private to the file unless exported. A file with neither is treated as a script, and its top-level declarations become global — which is why you'll sometimes see an empty export {} at the end of a file, purely to make it a module.

Named exports

TS math.ts
export const PI_ROUNDED = 3.14;

export function circleArea(r: number): number {
  return PI_ROUNDED * r * r;
}

export interface Circle {
  radius: number;
}
TS main.ts
import { circleArea, type Circle } from "./math";

const c: Circle = { radius: 2 };
console.log(circleArea(c.radius));
Output
12.56

Types and interfaces are exported and imported exactly like values. Imports must match an exported name; a typo is a compile error with a suggestion of the closest name.

Default exports

TS logger.ts
export default class Logger {
  constructor(private prefix: string) {}
  log(msg: string) {
    console.log(`${this.prefix} ${msg}`);
  }
}
TS app.ts
import Logger from "./logger";

new Logger("[app]").log("started");
Output
[app] started

A module can have one default export, and the importer chooses its local name. Many teams prefer named exports only, because they keep names consistent across the codebase and work better with auto-import and refactoring tools.

Renaming, namespaces and re-exports

TS patterns.ts
// rename on import
import { circleArea as area } from "./math";

// import everything as one object
import * as math from "./math";
console.log(math.PI_ROUNDED);

// re-export from an "index" file so callers have one import path
export { circleArea } from "./math";
export * from "./shapes";
export type { Circle } from "./math";

A folder's index.ts that re-exports its public pieces is called a barrel. Barrels make imports tidy, but very large ones can slow down builds and tests, because importing one thing loads everything the barrel references.

Type-only imports and exports

When you import something only to use it as a type, mark it with type:

TS type-only.ts
import type { User } from "./models";
import { saveUser, type SaveOptions } from "./db";

function register(user: User, opts: SaveOptions) {
  return saveUser(user, opts);
}

A type import is guaranteed to be removed during compilation, so it never loads the other file at runtime. This matters for three reasons: it avoids accidental circular imports, it keeps bundles smaller, and it's required by tools that compile one file at a time without seeing the whole project. The compiler option verbatimModuleSyntax (lesson 38) enforces it: any import used only as a type must be marked.

ES modules vs CommonJS

Two module systems exist in the JavaScript world:

ES modules (ESM)CommonJS (CJS)
Syntaximport / exportrequire() / module.exports
WhereBrowsers, modern Node, bundlersOlder Node code and packages
In Node.mjs files, or "type": "module" in package.json.cjs files, or the default

You always write import/export in TypeScript; the module setting in tsconfig.json decides what the compiled JavaScript uses. For Node projects, "module": "nodenext" makes TypeScript follow Node's actual rules — including the one that surprises everyone:

TS esm-node.ts
// In a Node ESM project, relative imports need the .js extension,
// even though the file on disk is math.ts:
import { circleArea } from "./math.js";

The extension refers to the file that will exist after compilation. Bundlers like Vite are more forgiving; for them use "moduleResolution": "bundler", which allows extension-less imports.

Path aliases

Deeply nested relative imports (../../../utils/format) can be replaced with aliases configured in tsconfig.json:

JSON tsconfig.json (excerpt)
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Then import { format } from "@/utils/format" works anywhere. Note that paths only tells the type checker where to look — your bundler or runtime must be configured with the same alias (Vite, webpack and Next.js all support this), or the import will fail when the code runs.

Dynamic imports

import() loads a module on demand and returns a promise. TypeScript still knows the module's exact types:

TS dynamic.ts
async function showChart() {
  const { renderChart } = await import("./charts");
  renderChart("#sales");
}

Bundlers split dynamically imported modules into separate files, so heavy code only downloads when it's actually needed.