ES Modules: Imports, Exports, and Module Boundaries

Learn how TypeScript organizes code across files using ES modules — named and default exports, imports, namespace imports, re-exports, barrel files, type-only imports/exports, dynamic imports, circular dependencies, and how TypeScript's module output interacts with ESM and CommonJS at runtime.

Almost no real TypeScript program lives in a single file. As soon as you split code across files, you need a shared vocabulary for what one file makes available to others and what it pulls in — that vocabulary is the ES module system, and TypeScript layers compile-time type checking on top of it without changing its runtime rules.

Named exports and default exports

A named export attaches an identifier to whatever you export — a function, a class, a const, a type, an interface — and the importing file must ask for that exact name (optionally renaming it with as). Named exports are explicit and greppable: import { createUser } from "./user" tells you precisely what's being pulled in, and your editor can jump straight to the definition.

A default export marks one value per module as 'the main thing this file exports.' The importing file can name it anything it likes: import User from "./user" and import Person from "./user" both import the same default export. This flexibility is also the downside — there's no canonical name to search for, and renaming during import can quietly hide what something actually is.

Most TypeScript style guides today (and the TypeScript team's own codebase) favor named exports almost exclusively, reserving default exports for cases like a single React component per file, where the file and the export are conceptually the same thing. A file can mix both, but doing so for everyday utility modules tends to create inconsistency across a codebase.

Namespace imports, re-exports, and barrel files

A namespace import (import * as utils from "./utils") pulls in every named export of a module as properties of a single object, utils.formatDate(...), utils.slugify(...). It's useful when a module exports many related helpers and you want them grouped under one recognizable prefix at the call site, but it defeats some tree-shaking if the bundler can't prove which properties you actually use.

A re-export forwards another module's exports without importing them into local scope first: export { createUser } from "./user" and export * from "./user" both make user.ts's exports available from the current file, as if they'd been declared here. This is the mechanism behind barrel files — an index.ts that gathers and re-exports everything from a folder of related modules so consumers can write import { createUser, deleteUser } from "./users" instead of reaching into individual files.

Barrel files are convenient for consumers but have real costs: they can create accidental circular dependencies, they often defeat tree-shaking in bundlers that aren't careful about side effects, and a large barrel can force TypeScript to load far more of a project's type graph than a given import actually needs, slowing down editor responsiveness. Use them at meaningful package boundaries, not reflexively for every folder.

import type and type-only exports

TypeScript lets you mark an import or export as type-only, meaning it exists purely for type checking and must be completely erased from the compiled JavaScript. import type { User } from "./user" tells the compiler (and anyone reading the code) that User is never used as a runtime value — only as a type annotation.

This matters more than it looks. Without import type, a bundler or transpiler that processes files one at a time (rather than type-checking the whole program) cannot always tell whether an imported name is a type or a value, because that requires knowing what the other file exports. Isolated-file transpilers like Babel, esbuild, and SWC solve this by requiring the type-only hint explicitly — get it wrong and you can end up importing something at runtime that doesn't exist there, or shipping dead type-only imports into your bundle.

You can also write export type { User } to explicitly mark a re-export as type-only, and starting with modern TypeScript you can mix type and value specifiers in a single import with inline type modifiers: import { type User, createUser } from "./user". The isolatedModules and verbatimModuleSyntax compiler options (covered in the tsconfig module) exist specifically to enforce this discipline project-wide.

Dynamic imports, circular dependencies, and ESM vs CommonJS

A dynamic import — const mod = await import("./chart") — loads a module at runtime instead of at parse time, returning a Promise that resolves to the module's exports (typed automatically from the target file). It's the standard way to code-split a bundle, lazy-load a heavy dependency, or conditionally load a module only when a feature is actually used.

A circular dependency happens when module A imports from module B and module B (directly or transitively) imports from module A. ES modules handle this better than CommonJS in principle — bindings are live references rather than copied values — but circular dependencies still frequently cause 'value is undefined at this point' bugs in practice, especially when a value is used at module-initialization time rather than inside a function body. The fix is almost always structural: extract the shared piece both modules depend on into a third module that neither of the originals needs to import back.

TypeScript can compile your ES module syntax down to different runtime module formats — CommonJS (require/module.exports), classic AMD, or modern ESM output — controlled by the module compiler option. esModuleInterop smooths over a real mismatch between the two systems: CommonJS has no native concept of a 'default export,' so without it, import React from "react" against a CommonJS package can fail or behave unexpectedly. Understanding which format your code actually runs as (check your package.json's "type" field and your bundler's output) is essential — TypeScript's own module syntax looks identical regardless of the target, but the runtime semantics are not interchangeable.

Named exports, default-less modules, and importing them

user.ts has no default export — every consumer must import User and createUser by their exact names, which keeps every usage searchable. import type { User } is erased entirely at compile time; only the import { createUser } line has any runtime effect.

// user.ts
export type User = {
  id: number;
};

export function createUser() {}

// main.ts
import { createUser } from "./user";
import type { User } from "./user";

const u: User = { id: 1 };
createUser();

Code Examples

user.ts exports a type (User) and a value (createUser) as named exports. app.ts imports the value normally and the type with import type, so a JavaScript-only build tool can see exactly which import line to strip. Expected result: User #1737059200000 import type { User } from "./user";

validators/index.ts re-exports everything from two sibling files as a barrel; form.ts pulls the whole barrel in as a namespace object (validators.isValidEmail). loadHeavyChart shows a dynamic import used for lazy loading — ./chart and its dependencies are only fetched when this function actually runs.

Common mistake: Namespace-importing a large barrel (import * as validators) can prevent a bundler from tree-shaking out validators you never call — prefer named imports from the barrel (import { isValidEmail } from "./validators") when you only need a few.

// user.ts
export type User = {
  id: number;
};

export function createUser() {
  return { id: Date.now() };
}

// app.ts
import { createUser } from "./user";
import type { User } from "./user";

function printUser(u: User) {
  console.log(`User #${u.id}`);
}

printUser(createUser());
// validators/email.ts
export function isValidEmail(v: string): boolean {
  return v.includes("@");
}

// validators/password.ts
export function isValidPassword(v: string): boolean {
  return v.length >= 8;
}

// validators/index.ts  (barrel file)
export * from "./email";
export * from "./password";

// form.ts
import * as validators from "./validators";

function validate(email: string, password: string) {
  return validators.isValidEmail(email) && validators.isValidPassword(password);
}

async function loadHeavyChart() {
  const { renderChart } = await import("./chart");
  renderChart();
}

Module dependency graph

Each arrow is a compile-time dependency; TypeScript walks this whole graph to type-check, while the bundler walks a runtime-only version of it (with type-only edges removed) to build the final JS.

user.ts               validators/index.ts (barrel)
  +-------------+         +---------------------------+
  | export type |         | export * from "./email"    |
  | User        |         | export * from "./password" |
  | export fn   |         +---------------------------+
  | createUser  |                 ^          ^
  +-------------+                 |          |
        ^                  email.ts     password.ts
        |
  app.ts imports { createUser }, import type { User }

Important Rules

Prefer named exports for most modules; reserve a default export for files that represent exactly one conceptual thing (a single React component, a single class).

Mark type-only imports and exports explicitly with import type / export type (or inline type modifiers) so isolated-file transpilers never have to guess.

Keep barrel files at intentional package boundaries, not on every folder — they can hide circular dependencies and blunt tree-shaking.

Use dynamic import() for genuinely optional or heavy code paths, not as a default way to import everything — it adds an asynchronous boundary that static imports don't have.

When you hit a circular-dependency bug, restructure by extracting the shared dependency into a new module rather than reordering imports and hoping.

Where this shows up

A typical production TypeScript app — a Node API or a React front end — is organized as dozens or hundreds of small modules: a types/ folder of type-only exports imported everywhere with import type, feature folders with a barrel index.ts exposing a clean public surface, and dynamic import() calls at route boundaries so each page of a web app only downloads the JavaScript it actually needs. Getting the export/import discipline right early is what keeps a codebase navigable as it grows past a handful of files.

import User from "./user"; // user.ts has no default export import { User } from "./user"; // or: import type { User } from "./user";

Importing a default export from a module that only has named exports is a compile error in TypeScript (and undefined at runtime under some CommonJS interop settings) — the module must actually declare export default for a default import to work.

import { User, createUser } from "./user"; // User is a type, createUser is a value, mixed without a hint import { type User, createUser } from "./user";

Under isolatedModules or verbatimModuleSyntax, an import of a type-only name without a type modifier can fail to compile cleanly with single-file transpilers, because they cannot see into user.ts to know User is type-only.

[multiple-choice] Given export type User = { id: number }; export function createUser() {} in user.ts, which import statement is correct for using both from another file? A. import User, createUser from "./user"; B. import { User, createUser } from "./user"; C. import default { User, createUser } from "./user"; D. import * from "./user";

B. import { User, createUser } from "./user"; — Both User and createUser are named exports, so both must be imported inside curly braces by their exact names; there is no default export to import bare.

[true-false] A namespace import (import * as utils from "./utils") always produces smaller bundles than importing only the specific named exports you use. A. True B. False

B. False — The opposite is often true — a namespace import can make it harder for a bundler to statically determine which exports are unused, reducing how effectively tree-shaking can remove dead code.

[multiple-choice] What is the main runtime effect of writing import type { User } from "./user"; instead of import { User } from "./user";? A. It makes the import lazy-loaded at first use. B. It has no effect — both compile to identical JavaScript. C. It guarantees the import is completely erased from the compiled JavaScript output. D. It converts the import into a CommonJS require call.

C. It guarantees the import is completely erased from the compiled JavaScript output. — import type tells the compiler this binding is only ever used as a type, so it is always stripped from the emitted JavaScript — unlike a plain import, which is kept because the compiler can't be sure it isn't used as a runtime value too.

Create two files, math.ts (exporting a named function add(a: number, b: number): number and a named type Operation = "add" | "subtract") and main.ts that imports add as a value and Operation as a type-only import, then calls add(2, 3).

// math.ts export type Operation = "add" | "subtract"; export function add(a: number, b: number): number { return a + b; } // main.ts import { add } from "./math"; import type { Operation } from "./math"; const op: Operation = "add"; console.log(add(2, 3));

Use export function and export type in math.ts; use import { add } from "./math" and import type { Operation } from "./math" in main.ts.