ES Modules in TypeScript


An import line is a promise. It says that a file can rely on a name, that a dependency exists at runtime or compile time, and that another module has chosen to make that name public. When those promises are vague, a codebase becomes difficult to move: internal details leak outward, circular dependencies appear, and a harmless refactor changes a package's runtime behaviour.
ES modules give TypeScript a vocabulary for making those boundaries explicit. The syntax is familiar, but the design work happens behind it: deciding what a module owns, what consumers are allowed to know, and how TypeScript's view of a module corresponds to the JavaScript that will actually run.
A Module Is a Boundary
A module is not just a file used for organisation. It is a boundary around responsibility. A useful module has a coherent reason to change and exposes a small surface that lets other code use that responsibility without depending on its implementation.
Consider a billing feature. Its route should not need to know whether invoices come from PostgreSQL, an API, or a test double. It should ask a module for an application-level answer:
// billing/get-open-invoices.ts
import { invoiceRepository } from "./invoice-repository";
export async function getOpenInvoices(accountId: string) {
const invoices = await invoiceRepository.findOpenByAccount(accountId);
return invoices.map((invoice) => ({
id: invoice.id,
dueDate: invoice.dueDate.toISOString(),
totalCents: invoice.totalCents,
}));
}The route imports getOpenInvoices, not a database connection and not the repository's SQL-shaped record. That makes the route easier to read, the repository easier to replace, and the public contract much smaller.
Small Surfaces Create Cheap Refactors
Consumers should depend on a module's stable intention, not its convenient internal helper. Every export is a commitment that future maintainers must preserve or deliberately break.
Export an Intentional Public Surface
Prefer named exports for stable application and library APIs. They make a consumer's dependency visible at the import site and reduce ambiguity during search and rename operations.
// money.ts
export type Currency = "CAD" | "USD";
export interface Money {
amountCents: number;
currency: Currency;
}
export function formatMoney({ amountCents, currency }: Money): string {
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency,
}).format(amountCents / 100);
}import { formatMoney, type Money } from "@/billing/money";Default exports can work, especially when a framework requires one, but they obscure the exported name and make broad refactors less direct. Use them where a convention calls for them—such as a Next.js page component—not as a substitute for deciding a module's public API.
The important distinction is between exported and exportable. A helper may be technically easy to export, but keeping it private means callers cannot quietly build a second feature around an implementation detail.
// Keep this private: it describes today's implementation.
function toDatabaseRow(invoice: Invoice): InvoiceRow {
return {
/* ... */
};
}
// Export this: it describes the useful behaviour.
export async function saveInvoice(input: SaveInvoiceInput): Promise<Invoice> {
// validation, persistence, and mapping stay inside the boundary
}Separate Type Dependencies from Runtime Dependencies
TypeScript understands two dependency graphs. The type graph is used to check your program. The runtime graph is the JavaScript the browser, Node.js, or a bundler must load. They overlap, but they are not identical.
Use import type whenever an imported name is used only as a type:
import type { Invoice } from "./invoice";
import { calculateTax } from "./calculate-tax";
export function calculateInvoiceTotal(invoice: Invoice) {
return invoice.subtotalCents + calculateTax(invoice);
}That declaration documents the dependency accurately and protects you from accidentally treating a type as a runtime value. It also makes mixed module systems less surprising: a type-only import should disappear from emitted JavaScript, whereas a normal import may cause a module to be evaluated.
This matters when modules have side effects. A file that registers a plugin, patches a global, or opens a connection has a runtime cost merely by being imported. Keep that behaviour explicit:
// app/bootstrap.ts
import "./register-observability";
import { startServer } from "./server";
startServer();Do not hide side effects behind a type-shaped abstraction. A reader should be able to tell which imports are just contracts and which imports make work happen.
Make Resolution Match the Runtime
The compiler can only protect a program when it models the environment that will execute it. A project using modern Node.js ESM should not be configured as if it were emitting legacy CommonJS, and a bundler application should use resolution settings that reflect its bundler.
For a modern Node package, a conservative starting point is often:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,
"strict": true
}
}verbatimModuleSyntax is valuable because it asks you to write imports that describe your intention. Type-only imports must be marked as such, and TypeScript does less behind-the-scenes rewriting. The result is a tighter connection between source code and emitted JavaScript.
For a Next.js application, follow the framework's generated TypeScript configuration rather than copying a library setting wholesale. Next.js handles compilation and bundling, so its recommended configuration is the runtime contract to respect. The principle is the same in both cases: do not use compiler options to make an incorrect module graph appear to work.
Path Aliases Are Not Runtime Resolution
A paths entry can help TypeScript understand @/features/orders, but
Node.js does not automatically understand it. Your framework, bundler, test
runner, and production runtime must agree on the alias or the application will
type check and then fail at runtime.
Use Barrel Files Carefully
An index.ts barrel can offer a clean feature-level entry point:
// features/invoices/index.ts
export { getOpenInvoices } from "./get-open-invoices";
export type { InvoiceSummary } from "./types";import { getOpenInvoices, type InvoiceSummary } from "@/features/invoices";That is useful when the barrel is deliberately curated. It is less useful when it re-exports every file automatically or when internal files import through their own barrel. In that situation, a local dependency can turn into a cycle that is hard to see:
index.ts -> get-open-invoices.ts -> index.tsInside a feature, import the neighbouring file directly. Reserve the barrel for callers outside the boundary. This gives the feature an obvious front door while keeping implementation wiring straightforward.
Avoid huge root-level barrels in application code. They erase architectural locality: import { something } from "@/lib" tells a reviewer almost nothing about where the dependency comes from or which boundary it crosses.
Design Package Entry Points
If you publish a package, package.json is part of your module API. The exports map specifies which paths consumers may import; it is a chance to prevent deep imports into implementation folders.
{
"name": "@acme/billing",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"import": "./dist/testing.js"
}
}
}The map says that production consumers receive the package root and test consumers receive a supported testing entry point. It does not promise that @acme/billing/dist/repository/internal.js will exist forever. That constraint is kind to both the package author and package users: it makes compatibility an intentional decision.
Treat declarations as first-class output. A package that emits JavaScript but exposes stale, missing, or incompatible .d.ts files has not shipped a usable TypeScript API. Test a real consumer fixture when the package boundary matters.
A Module Review Checklist
Before approving a module change, ask:
- Does this export express a stable behaviour or leak a temporary implementation detail?
- Is each import a runtime dependency, a type-only dependency, or an intentional side-effect import?
- Do TypeScript, tests, the bundler, and production resolve the specifier the same way?
- Would importing through a local barrel introduce a cycle?
- If this is a package, are the supported entry points explicit in
exports? - Can a consumer get its job done without reaching into an internal directory?
These questions catch more than style issues. They find hidden runtime work, accidental public APIs, and configuration gaps before those gaps become release problems.
Key Takeaways
- Treat every import and export as a contract across a module boundary.
- Export stable behaviour; keep representation and wiring private.
- Use
import typeto distinguish compile-time contracts from runtime work. - Configure TypeScript to model the environment that will execute the code.
- Curate barrels at feature boundaries, not everywhere.
- Use package export maps to make supported public entry points explicit.
Next, we will move from module boundaries to declaration files and third-party types: how to extend a library safely when its public type surface does not yet describe the API you need.