TL;DR
Organize TypeScript code with ES modules. Learn named exports, default exports, type-only imports, re-exports, and barrel files.
Key concepts
- TypeScript modules
- TypeScript import export
- type-only imports
- barrel files TypeScript
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/exportonly 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-fileimport/exportsyntax is shown inno-runblocks 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.
What You'll Learn
By the end of this lesson you will be able to read any import line and say exactly what it asks the loader for — a named export, the default one, a whole namespace, or nothing at all at run time — and you will build the resolution-and-caching engine that sits underneath all of that syntax, so a module that is imported from ten places still evaluates once.
This lesson is not tied to a capstone milestone. What it is for is the shape of every real codebase you will work in: everything you have written so far has lived in a single file, and the moment it does not, the questions that matter are which names cross a file boundary, which of them exist only for the compiler, and in what order the files actually run. The last of those is where the surprising bugs live.
You arrive able to write functions and classes and to annotate what they take and return (Functions, Classes And OOP). Nothing about a module changes those; what is new is the boundary they sit behind, and the fact that TypeScript needs to tell a type crossing that boundary from a value crossing it.
Named Exports
Named exports let you export multiple values from a module. Importers must use the exact names.
That constraint is the reason to prefer them. Because the name is fixed at both ends, every importer of a given function spells it the same way, so searching the codebase for that spelling finds all of them, and a rename becomes a compiler error at each call site rather than a silent divergence. A default export gives the importer the freedom to call it anything, which is occasionally what you want and is usually how a module ends up known by four different names. Read the two sections below as that trade — the machinery differs very little; what differs is who chooses the name.
// 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; put them back so the program runs and logs the endpoint. Then answer why this order: which line could not have been moved, and what does the runner do if you move it?
const endpoint = apiUrl + "/activities";console.log("endpoint:", endpoint);const withConfig = registry.set("config", "https://api.example.com");const registry = new Map<string, string>();const apiUrl = withConfig.get("config") ?? "";
A Simulated Module Graph
A three-module app — a types module, a service module, and an app module — modelled in one file, since the runner cannot import across files. It arrives in two halves, and you finish the second one.
First the types module and the service, worked in full. The two query methods are the pattern everything else here is built from: each one filters this.tasks on a single field and hands back a Task[].
// 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 — the two query methods, fully worked.
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);
}
}
const demo = new TaskService();
demo.addTask("Write docs", "high");
demo.addTask("Fix bug", "high");
demo.updateStatus(1, "done");
console.log("High priority:", demo.getByPriority("high").map(t => t.title));
console.log("Done:", demo.getByStatus("done").map(t => t.title));
That prints High priority: [ 'Write docs', 'Fix bug' ] and Done: [ 'Write docs' ].
Now the same service with the app module attached — and with one method left for you. getSummary is stubbed to return all zeros, so the program runs and reports a summary that is visibly wrong:
// 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);
}
// TODO: return one count per TaskStatus, keyed by the status name.
// The return type Record<TaskStatus, number> is the whole spec: every
// member of TaskStatus must appear as a key. getByStatus already does
// the filtering — this method only has to count what it returns.
getSummary(): Record<TaskStatus, number> {
return { todo: 0, "in-progress": 0, done: 0 };
}
}
// 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})`)
);
Run it as-is and the first line reads Task Summary: {"todo":0,"in-progress":0,"done":0}, which cannot be right — four tasks were added and two had their status changed. Fill in getSummary using getByStatus, which is already written directly above it, and the line becomes Task Summary: {"todo":2,"in-progress":1,"done":1}. The rest of the output does not change either way, so that one line is your whole signal.
The reason this is a modules example and not a classes example is what crosses each boundary. The app module never touches this.tasks; it only calls methods the service module chose to expose, and it only knows Priority and TaskStatus because the types module named them. getSummary's return type Record<TaskStatus, number> is that dependency made concrete — the app module can rely on every status appearing as a key precisely because the types module owns the list of statuses, and neither module has to restate it.
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:
- Skip the cache. In
requireModule, remove theif (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, soconfigAgainis a different object fromconfig— TODO 2's check fires withAssertionError: TODO 2: a second require returns the SAME cached exports. (TheconfigRunscheck 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. - 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. Resolvingclientruns its factory, which pullsclient-configin on first use (factories run lazily, so definingclientbeforeclient-configis fine), so you getclient endpoint: https://api.example.com/activitiesandclient-config evaluated: true. This changes the echoed output, not any check, and shows one module resolving another by name through the same registry.
When two modules import each other
The Key Takeaways below advise putting shared types in their own module to avoid circular dependencies. This block is why. A cycle does not usually announce itself — the loader resolves it without complaint and the damage shows up as a value that is mysteriously undefined. The registry from the build task models it faithfully, because the cache-before-evaluate step is exactly what a real loader does.
Debug
Two modules import each other. The program starts at config, which imports report before it has finished setting up its own exports; report imports config back and immediately reads a value out of it. Nothing errors — the loader resolves the cycle quite happily — and then the assert fails on a title containing undefined. Commit a hypothesis about what is inside config's exports at the moment report reads them, then fix it so the title reads report (timeout 30).
import assert from "node:assert";
// A one-file model of a module graph. Each "module" is a factory whose body is
// its top-level code; the cache is what makes a module evaluate only once.
const factories = new Map<string, (exports: Record<string, unknown>) => void>();
const cache = new Map<string, Record<string, unknown>>();
function define(name: string, factory: (exports: Record<string, unknown>) => void): void {
factories.set(name, factory);
}
// A real loader puts the (still empty) exports object in the cache BEFORE running
// the module body, which is what stops an import cycle from looping forever.
function require_(name: string): Record<string, unknown> {
const hit = cache.get(name);
if (hit) return hit;
const exports: Record<string, unknown> = {};
cache.set(name, exports);
factories.get(name)!(exports);
return exports;
}
// config.ts — the entry point. It imports report.ts before setting its own exports.
define("config", (exports) => {
require_("report");
exports.timeout = 30;
});
// report.ts — imports config.ts back (the cycle) and reads it at TOP LEVEL.
define("report", (exports) => {
const config = require_("config");
exports.title = `report (timeout ${config.timeout})`;
});
const config = require_("config");
const report = require_("report");
assert.strictEqual(report.title, "report (timeout 30)", "the report title should carry the resolved timeout, got " + report.title);
console.log(report.title);Expected output:
report (timeout 30)
Transfer
A TypeScript module is a file, and what leaves it is whatever you mark with export. Rust splits the same job differently: a module is declared with mod, it is private by default, and pub is what makes an item visible outside it. Both languages are answering the same question with different defaults. Which statement names what actually transfers, rather than a surface resemblance?
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!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.