Skip to editor content
learningtypescript.orglesson 14 of 25

Modules

Modules let you split your code into separate files, each with its own scope. TypeScript uses ES modules with import and export statements, adding type-only imports so tools that compile one file at a time can tell types from values.

How this lesson runs. Real import/export only work across separate files, but the runner executes everything on this page as a single file. So the runnable examples below simulate a module graph in one file (objects standing in for modules), and the cross-file import/export syntax is shown in no-run blocks and comments — code you read rather than execute. The build task at the end models the one mechanic the syntax hides (single-evaluation caching) in plain functions you can run.

Named Exports

Named exports let you export multiple values from a module. Importers must use the exact names.

// Simulating module exports in a single file

// Named exports
const PI = 3.14159;
const E = 2.71828;

function add(a: number, b: number): number {
  return a + b;
}

function multiply(a: number, b: number): number {
  return a * b;
}

interface MathResult {
  operation: string;
  result: number;
}

function calculate(op: string, a: number, b: number): MathResult {
  const result = op === "add" ? add(a, b) : multiply(a, b);
  return { operation: op, result };
}

// Using the exported values
console.log(`PI = ${PI}`);
console.log(`E = ${E}`);
console.log(`add(3, 4) = ${add(3, 4)}`);
console.log(`multiply(5, 6) = ${multiply(5, 6)}`);

const calc = calculate("add", 10, 20);
console.log(`${calc.operation}(10, 20) = ${calc.result}`);

The fence above simulates named exports in one file. Across real files, the same code looks like this — shown, not run, because the runner has no second file to import from:

// math.ts
export const PI = 3.14159;
export function add(a: number, b: number): number {
  return a + b;
}

// main.ts
import { add, PI } from "./math";
console.log(`${PI}, ${add(3, 4)}`);

A named export must be imported by its exact name. The next block reasons about what happens when the import shape does not match the export shape.

Predict

greet.ts has a DEFAULT export. main.ts tries to import it with curly braces — { greet } — which is the syntax for a NAMED export. What happens when a real ES-module loader (Node running the compiled modules) resolves main.ts?

// greet.ts
export default function greet(name: string): string {
return "Hello, " + name;
}

// main.ts
import { greet } from "./greet";
console.log(greet("Ada"));

Default Exports

A module can have one default export, typically its primary value.

// Default export pattern — one main export per module
class Logger {
  private prefix: string;
  private logs: string[] = [];

  constructor(prefix: string) {
    this.prefix = prefix;
  }

  log(message: string): void {
    const entry = `[${this.prefix}] ${message}`;
    this.logs.push(entry);
    console.log(entry);
  }

  warn(message: string): void {
    const entry = `[${this.prefix}] WARNING: ${message}`;
    this.logs.push(entry);
    console.log(entry);
  }

  getHistory(): string[] {
    return [...this.logs];
  }
}

// Using the default export
const logger = new Logger("App");
logger.log("Application started");
logger.log("Loading configuration");
logger.warn("Config file not found, using defaults");

console.log(`\nLog history: ${logger.getHistory().length} entries`);

Namespace Imports and Re-exports

Group all exports under a single name, or re-export from other modules.

// Namespace import pattern: import * as Name
// Simulating with an object

const MathUtils = {
  PI: 3.14159,
  E: 2.71828,
  add: (a: number, b: number) => a + b,
  subtract: (a: number, b: number) => a - b,
  multiply: (a: number, b: number) => a * b,
  divide: (a: number, b: number) => b !== 0 ? a / b : NaN,
};

const StringUtils = {
  capitalize: (s: string) => s.charAt(0).toUpperCase() + s.slice(1),
  reverse: (s: string) => s.split("").reverse().join(""),
  truncate: (s: string, len: number) =>
    s.length > len ? s.slice(0, len) + "..." : s,
};

// Barrel file pattern — re-export from index
// In real code: export { MathUtils } from './math';
//               export { StringUtils } from './strings';

console.log("Math operations:");
console.log(`  PI = ${MathUtils.PI}`);
console.log(`  3 + 4 = ${MathUtils.add(3, 4)}`);
console.log(`  10 / 3 = ${MathUtils.divide(10, 3).toFixed(4)}`);

console.log("\nString operations:");
console.log(`  capitalize: ${StringUtils.capitalize("hello world")}`);
console.log(`  reverse: ${StringUtils.reverse("TypeScript")}`);
console.log(`  truncate: ${StringUtils.truncate("A very long string", 10)}`);

Type-Only Imports

TypeScript supports importing only types, which are erased at compile time.

// Type-only imports are erased at runtime
// In real code: import type { User } from './types';

// Types and interfaces
type UserRole = "admin" | "editor" | "viewer";

interface User {
  id: number;
  name: string;
  role: UserRole;
}

interface Post {
  id: number;
  title: string;
  authorId: number;
}

// Runtime values that use the types
function createUser(name: string, role: UserRole): User {
  return { id: Math.floor(Math.random() * 1000), name, role };
}

function getUserPosts(user: User): Post[] {
  return [
    { id: 1, title: `${user.name}'s First Post`, authorId: user.id },
    { id: 2, title: `${user.name}'s Second Post`, authorId: user.id },
  ];
}

function formatUser(user: User): string {
  return `${user.name} (${user.role}) #${user.id}`;
}

const admin = createUser("Alice", "admin");
const posts = getUserPosts(admin);

console.log(`User: ${formatUser(admin)}`);
console.log(`Posts:`);
posts.forEach(p => console.log(`  - ${p.title}`));

Recall

Without scrolling up: a module exports SOME of its bindings and keeps the rest to itself — anything you don't export is invisible to importers. You met the same encapsulation idea in *Classes And OOP* with a different keyword. What was it, and how is a non-exported binding the module-level version of it?

Arrange the code

Reassemble a program that models resolving a value out of a module registry: build a typed registry map, register a config entry (Map.set returns the map back), read the url out of it, build an endpoint from that url, and log it. The lines are shuffled. Each const consumes the binding above it, so only one order runs top-to-bottom and logs the endpoint.

  1. const endpoint = apiUrl + "/activities";
  2. console.log("endpoint:", endpoint);
  3. const withConfig = registry.set("config", "https://api.example.com");
  4. const registry = new Map<string, string>();
  5. const apiUrl = withConfig.get("config") ?? "";

A Simulated Module Graph

The example below models a three-module app — a types module, a service module, and an app module — in one file, since the runner cannot import across files. It runs as-is:

// Build a modular task management system

// Types module
type Priority = "low" | "medium" | "high";
type TaskStatus = "todo" | "in-progress" | "done";

interface Task {
  id: number;
  title: string;
  priority: Priority;
  status: TaskStatus;
}

// Service module
class TaskService {
  private tasks: Task[] = [];
  private nextId = 1;

  addTask(title: string, priority: Priority): Task {
    const task: Task = {
      id: this.nextId++,
      title,
      priority,
      status: "todo"
    };
    this.tasks.push(task);
    return task;
  }

  updateStatus(id: number, status: TaskStatus): void {
    const task = this.tasks.find(t => t.id === id);
    if (task) task.status = status;
  }

  getByPriority(priority: Priority): Task[] {
    return this.tasks.filter(t => t.priority === priority);
  }

  getByStatus(status: TaskStatus): Task[] {
    return this.tasks.filter(t => t.status === status);
  }

  getSummary(): Record<TaskStatus, number> {
    return {
      "todo": this.tasks.filter(t => t.status === "todo").length,
      "in-progress": this.tasks.filter(t => t.status === "in-progress").length,
      "done": this.tasks.filter(t => t.status === "done").length,
    };
  }
}

// App module — uses both
const service = new TaskService();

service.addTask("Write docs", "high");
service.addTask("Fix bug", "high");
service.addTask("Add tests", "medium");
service.addTask("Update README", "low");

service.updateStatus(1, "done");
service.updateStatus(2, "in-progress");

console.log("Task Summary:", JSON.stringify(service.getSummary()));

console.log("\nHigh priority:");
service.getByPriority("high").forEach(t =>
  console.log(`  [${t.status}] ${t.title}`)
);

console.log("\nIn progress:");
service.getByStatus("in-progress").forEach(t =>
  console.log(`  ${t.title} (${t.priority})`)
);

Try It Yourself

Reading about import and export is not the same as building the machinery beneath them — and there is a catch this whole page has worked around: real import/export only run across separate files, and everything here runs as one file, which is why every example above shows the module syntax or simulates a module in-file rather than importing across files.

One mechanic the syntax hides is worth building for real: a module loader caches. Import the same module from ten different files and its top-level code runs exactly once — every importer is handed the same exports object, not a fresh copy. That is why a module's setup (opening a connection, reading config) happens a single time no matter how widely it is imported.

So instead of the syntax, you will build that mechanic, modeled in plain typed functions: a tiny module registry. This is a build task — a small program that reports its own pass/fail. defineModule(name, factory) registers a module by name; requireModule(name) resolves it by name and — the key part — evaluates each module's factory at most once, then caches and hands back the same exports on every later call, exactly as a real loader does with your files. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.

Build

Finish the build. Three functions are stubbed, and the checks below them fail until each behaves correctly. Run it as-is to see which check fails first, decide what that function is missing, then implement them until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

import assert from "node:assert";

// A tiny module system, modeled in plain functions. Real import/export only run
// across separate files, so we build the MECHANICS underneath the syntax here:
// register a module by name, resolve it by name, and evaluate each factory at
// most once (then hand back the cached exports). Do NOT change these two stores.
type Exports = Record<string, unknown>;
type Factory = () => Exports;

const registry = new Map<string, Factory>(); // name -> factory
const cache = new Map<string, Exports>(); // name -> evaluated exports

// TODO 1: register a module. Store factory under name in the registry
//   (registry.set(name, factory)), so registry.has(name) becomes true.
//   defineModule("config", () => ({ url: "x" })) -> registry.has("config") === true
function defineModule(name: string, factory: Factory): void {
// your code here
}

// TODO 2: resolve a module by name. If it was resolved before, return the cached
//   exports. Otherwise run its factory ONCE, cache the result, and return it.
//   Throw an Error("unknown module: " + name) if the name was never defined.
//   requireModule("config") -> the exports object (factory runs only the first time)
function requireModule(name: string): Exports {
// your code here
return {}; // replace this
}

// TODO 3: return true if a module has already been evaluated (its exports are
//   cached), false otherwise. A defined-but-never-required module is false.
//   isEvaluated("config") -> false before first require, true after
function isEvaluated(name: string): boolean {
// your code here
return false; // replace this
}

// --- Build checks: these must all pass. Do not edit below this line. ---
let configRuns = 0;

defineModule("config", () => {
configRuns += 1;
return { apiUrl: "https://api.example.com" };
});
assert.strictEqual(registry.has("config"), true, "TODO 1: defineModule should register the module by name");
assert.strictEqual(isEvaluated("config"), false, "TODO 3: a defined-but-never-required module is not evaluated yet");

const config = requireModule("config");
assert.deepStrictEqual(config, { apiUrl: "https://api.example.com" }, "TODO 2: requireModule should return the factory's exports");
assert.strictEqual(isEvaluated("config"), true, "TODO 3: a required module is evaluated");

const configAgain = requireModule("config");
assert.strictEqual(configAgain, config, "TODO 2: a second require returns the SAME cached exports");
assert.strictEqual(configRuns, 1, "TODO 2: a module's factory must run exactly once, no matter how many requires");

assert.throws(() => requireModule("nope"), /unknown module: nope/, "TODO 2: requireModule should throw for an undefined name");

console.log("All checks passed.");
console.log("config apiUrl:", (requireModule("config") as { apiUrl: string }).apiUrl);
console.log("config factory runs:", configRuns);
console.log("nope is defined:", registry.has("nope"));

Expected output: All checks passed. config apiUrl: https://api.example.com config factory runs: 1 nope is defined: false

Once it passes, try two variations and predict each before running:

  1. Skip the cache. In requireModule, remove the if (cache.has(name)) return cache.get(name)!; line so it looks the factory up and runs it every time. Predict which check fails first before running. Now each require builds a fresh exports object rather than reusing the cached one, so configAgain is a different object from config — TODO 2's check fires with AssertionError: TODO 2: a second require returns the SAME cached exports. (The configRuns check would also fail, but the identity check comes first.) An instructive assert failure showing that the cache is what makes a module's exports a singleton.
  2. A module that requires another. After the checks pass, add defineModule("client", () => { const cfg = requireModule("client-config") as { apiUrl: string }; return { endpoint: cfg.apiUrl + "/activities" }; }); defineModule("client-config", () => ({ apiUrl: "https://api.example.com" })); console.log("client endpoint:", (requireModule("client") as { endpoint: string }).endpoint); console.log("client-config evaluated:", isEvaluated("client-config")); below the logs. Predict the two new lines before running. Resolving client runs its factory, which pulls client-config in on first use (factories run lazily, so defining client before client-config is fine), so you get client endpoint: https://api.example.com/activities and client-config evaluated: true. This changes the echoed output, not any check, and shows one module resolving another by name through the same registry.

Key Takeaways

  • Each TypeScript file is a module with its own scope
  • Use named exports when a module provides multiple items
  • Use default exports for a module's primary value
  • Namespace imports (import * as X) group all exports under one name
  • Barrel files (index.ts) simplify imports by re-exporting from a single entry point
  • Type-only imports (import type) are fully erased at compile time — they never become real imports
  • Avoid circular dependencies by putting shared types in separate modules

Pro Tip: Follow "one thing per file" for large types and classes, but group small related utilities together. Use barrel files for clean public APIs, but only re-export what consumers actually need.

Next Steps

Now that you can organize code across modules, it is time to configure how the compiler checks and builds all of them. Next, you'll learn tsconfig.json — strict mode, null safety, compiler targets, and the flags that decide how strictly your whole project is type-checked.

Ready to continue? Head to Config and Tsconfig!

Next lesson

Config And Tsconfig

Learn tsconfig.json to configure your TypeScript project. Master strict mode, compiler targets, path aliases, and essential flags.

20 min