TypeScript Modules, Imports, and Exports
A practical guide to TypeScript modules — ESM imports and exports, type-only imports, default vs named exports, barrel files, and how tsconfig moduleResolution decides what gets resolved.
What you'll learn
- ✓How ES modules work and how TypeScript layers on top of them
- ✓Named exports vs default exports — when to use each
- ✓Type-only imports and exports (`import type`)
- ✓Re-exports, namespace imports, and side-effect imports
- ✓What barrel files are, and the pros and cons
- ✓What `moduleResolution` does in tsconfig.json
Prerequisites
- •You can read basic TypeScript — see Interfaces and Type Aliases
Every non-trivial TypeScript project is dozens or hundreds of files. The rules for moving code across those files are simple in the common case and full of gotchas at the edges. This post walks through the entire module system — what to write, what each form means, and which tsconfig knob controls what.
A module, in one sentence
A module in modern TypeScript is a file with at least one top-level import or export. Anything else is a plain script whose top-level declarations leak into a global scope. Almost every file in a real project is a module.
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
// app.ts
import { add } from "./math";
console.log(add(2, 3));
That is the whole pattern. The rest of the post is the variations and the configuration.
Named exports
A named export attaches an identifier to the module’s public surface:
// shapes.ts
export interface Point { x: number; y: number; }
export function distance(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
export const ORIGIN: Point = { x: 0, y: 0 };
Consumers pick what they want by name:
import { Point, distance, ORIGIN } from "./shapes";
You can rename on import:
import { distance as dist } from "./shapes";
Or export an already-declared identifier in one statement at the end of the file:
function add(a: number, b: number): number { return a + b; }
function sub(a: number, b: number): number { return a - b; }
export { add, sub };
Either style is fine. Pick one and stay consistent within a file.
Default exports
A module can have at most one default export:
// logger.ts
export default function log(message: string): void {
console.log(`[log] ${message}`);
}
The importer chooses the name:
import log from "./logger";
import myLogger from "./logger"; // same export, different local name
Defaults look concise but have real downsides:
- The local name is arbitrary, so cross-file searches become harder.
- Refactoring tools cannot always rename them across the codebase.
- A file with one default is hard to grow — adding a second public symbol forces a mix of
defaultandnamedimports.
Many style guides ban default exports outright in application code. Libraries sometimes use them for a single main export. When in doubt, prefer named exports.
Type-only imports and exports
TypeScript runs both at design time (types) and at build time (emit). A type that is only used as a type should not produce a runtime import statement, especially under bundlers and isolatedModules. Mark it explicitly:
// user.ts
export interface User { id: number; name: string; }
export function makeUser(name: string): User { return { id: Date.now(), name }; }
// app.ts
import { makeUser } from "./user";
import type { User } from "./user";
function greet(user: User): string {
return `Hello, ${user.name}`;
}
import type is fully erased — the emitted JavaScript has no reference to User. You can also annotate a single specifier:
import { makeUser, type User } from "./user";
The export type form is symmetric:
export type { User } from "./user"; // re-export only the type
If you are writing a library or work with isolatedModules: true (Vite, esbuild, swc, Bun), using type consistently is essentially mandatory. In application code on tsc it is best practice and helps tree-shaking.
Try it yourself. In a small project, create types.ts with an exported interface. Import it from another file once with import { X } and once with import type { X }. Build with tsc and inspect the emitted .js. Confirm only the value import appears at runtime.
Namespace imports
You can grab an entire module under one local name:
import * as math from "./math";
math.add(1, 2);
math.sub(5, 3);
This is mostly useful when the module exports many helpers and you want them grouped, or for interop with old CommonJS modules under esModuleInterop. In typical app code, named imports are clearer.
Side-effect imports
Some modules are imported purely for their side effects — registering routes, patching globals, importing CSS in a bundler:
import "./register-shortcuts";
import "./styles.css";
No identifiers are introduced. The file is executed for its top-level statements only. Use this sparingly; side-effect modules are easy to lose track of.
Re-exports and barrel files
A barrel is an index.ts that re-exports from several files in the same folder, giving consumers a single entry point.
// shapes/circle.ts
export class Circle { constructor(public radius: number) {} }
// shapes/square.ts
export class Square { constructor(public side: number) {} }
// shapes/index.ts
export { Circle } from "./circle";
export { Square } from "./square";
Now elsewhere:
import { Circle, Square } from "./shapes";
Re-export forms:
export { Circle } from "./circle"; // re-export named
export { default as Logger } from "./logger"; // re-export a default as named
export * from "./circle"; // re-export everything
export * as circle from "./circle"; // re-export as a namespace
Pros of barrels
- Consumers do not need to know your folder layout.
- The public API of a folder is documented in one file.
- Renaming files inside the folder does not break callers.
Cons of barrels
- Tree-shaking can suffer. Some bundlers cannot eliminate unused re-exports through deep barrel chains, and your final bundle balloons.
- Circular imports appear more often. A barrel pulls in every sibling, so any sibling that imports a different sibling through the barrel can create a cycle.
- Cold-start cost rises in large monorepos — even running a single test pulls in every module the barrel re-exports.
A reasonable rule: use barrels for small, stable folders that form a real module boundary (a UI component library, a domain folder of 5-15 files). Avoid mega-barrels at the root of a large app.
Default vs named: practical rule
For application code: named exports for everything, no defaults.
For libraries: a single default for the main entry can be ergonomic, with everything else named.
The reason is simple — named exports give you a single canonical local name, which is what every other tool (search, rename, autoimport) reasons about.
Where does ./foo actually go?
This is moduleResolution in tsconfig.json. Three modes matter today.
"node" (a.k.a. “classic Node CommonJS”)
The legacy Node CJS algorithm. ./foo looks for ./foo.ts, ./foo.tsx, ./foo/index.ts. It does not consider package.json exports fields and is now considered out-of-date.
"node16" / "nodenext"
Models real Node ESM. You write file extensions in your imports — import "./foo.js" even though the file on disk is foo.ts — and Node-style package exports are respected. Use this when targeting Node and emitting ESM.
"bundler"
Designed for Vite, esbuild, Webpack, swc, Bun, and similar. Extensions are optional, package.json exports are honoured, and the compiler does not try to play Node. This is the right choice for nearly every modern application build.
// tsconfig.json
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022",
"strict": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
}
}
verbatimModuleSyntax: true is the modern recommendation — it forces the type keyword on type-only imports and stops tsc from rewriting your module syntax in surprising ways.
A worked example
A small folder with a barrel, type-only imports, and a single default-free public surface.
// src/users/types.ts
export interface User {
id: number;
name: string;
email: string | null;
}
export type Role = "admin" | "editor" | "viewer";
// src/users/service.ts
import type { Role, User } from "./types";
export function canEdit(user: User, role: Role): boolean {
return role === "admin" || role === "editor";
}
export function displayName(user: User): string {
return user.email ? `${user.name} <${user.email}>` : user.name;
}
// src/users/index.ts
export type { Role, User } from "./types";
export { canEdit, displayName } from "./service";
// src/app.ts
import { canEdit, displayName, type User } from "./users";
const alice: User = { id: 1, name: "Alice", email: "a@example.com" };
console.log(displayName(alice));
console.log(canEdit(alice, "admin"));
Every public surface flows through users/index.ts. Types are imported with type. Values are imported as ordinary specifiers. No defaults, no namespace imports, no side-effect imports.
Try it yourself. Replace the barrel above with a deep import (import { User } from "./users/types"). Now move types.ts to domain/types.ts and watch how many call sites break. Put the barrel back and repeat the move — only users/index.ts changes.
A note on namespace
The namespace keyword is a holdover from before ES modules existed. You will see it in old DefinitelyTyped declarations and global ambient files, but you should not write new code with it for organisation. Modules are the right tool. The only legitimate use today is augmenting a global type or a third-party module:
declare global {
interface Window {
myAppDebug?: { traceId: string };
}
}
export {};
The trailing export {}; keeps the file a module so the declare global block has the right semantics.
Recap
You now know:
- A module is any file with a top-level
importorexport. - Named exports are the safe default; defaults complicate refactoring.
import typeandexport typekeep types out of emitted JavaScript and are essential underisolatedModules/verbatimModuleSyntax.- Barrel files simplify call sites at the cost of tree-shaking and cycle risk; use them on small, stable folders.
- Re-exports come in five forms — named, default-as-named, star, namespace, and type.
moduleResolution: "bundler"is the right setting for modern app builds;"node16"/"nodenext"for real Node.namespaceis legacy for new code; reach for it only when augmenting globals or third-party modules.
Next steps
You now have the type system fundamentals — basics, interfaces, generics, narrowing, and modules. The next series turns to practical project work with FastAPI, the most popular Python framework for typed APIs, starting with routes and Pydantic models.
→ Next: FastAPI Routes and Pydantic Models
Related: Narrowing and Guards, Generics Basics.
Questions or feedback? Email codeloomdevv@gmail.com.