Modules
ES module import/export in TypeScript, import type, barrel re-exports, path aliases, moduleResolution strategies, and isolatedModules.
TypeScript uses the same ES module syntax as JavaScript and adds import type for type-only imports. The tsconfig.json moduleResolution strategy controls how module specifiers are resolved - getting this wrong causes builds that typecheck locally but fail in production.
Named exports, default exports, and import type work as in ES modules. import type is stripped entirely at compile time - it emits no runtime import statement, which prevents accidental side-effect imports and keeps bundler tree-shaking accurate.
// user.ts
export type UserId = string;
export interface User {
id: UserId;
name: string;
role: "admin" | "member";
}
export function createUser(name: string): User {
return { id: crypto.randomUUID(), name, role: "member" };
}
export default createUser;// app.ts
import createUser, { type User, type UserId } from "./user";
// ^ "import type" syntax: User and UserId are stripped at compile time.
// createUser is a value import - it survives into the JS output.
const user: User = createUser("Ada");
const id: UserId = user.id;
console.log(id);Re-exports collect related exports from multiple files into a single entry point (a "barrel"). This keeps import paths short for consumers while the implementation stays split across files.
// lib/auth/session.ts
export interface Session { token: string; expiresAt: Date }
export function parseSession(raw: string): Session { /* ... */ return {} as Session; }
// lib/auth/user.ts
export interface AuthUser { id: string; email: string }
export function getUser(token: string): Promise<AuthUser> { /* ... */ return Promise.resolve({} as AuthUser); }
// lib/auth/index.ts - barrel
export type { Session } from "./session";
export { parseSession } from "./session";
export type { AuthUser } from "./user";
export { getUser } from "./user";// pages/api/me.ts - clean import from the barrel
import { parseSession, getUser } from "@/lib/auth";moduleResolution in tsconfig.json controls how TypeScript resolves module specifiers. The bundler strategy matches Vite, esbuild, and Next.js - it allows extension-less imports and path aliases. node16 / nodenext require explicit .js extensions (even for .ts source files), matching Node.js ESM resolution.
// tsconfig.json
{
"compilerOptions": {
// "bundler" for Vite/Next.js/esbuild - no extensions required, path aliases work
"moduleResolution": "bundler",
"module": "ESNext",
// Path aliases - @/ maps to project root
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}// With moduleResolution: "bundler" - clean alias imports work
import { Button } from "@/components/Button";
import type { User } from "@/lib/types";
// With moduleResolution: "node16" - explicit extensions required
// import { Button } from "@/components/Button.js"; // .js even for .ts sourceisolatedModules: true requires every file to be compilable in isolation - no const enum across files, no type-only re-exports without export type. This matches how esbuild, swc, and Babel transform TypeScript: one file at a time, without full type information.
// WITHOUT isolatedModules - this compiles fine with tsc but breaks under esbuild/swc:
export { SomeType } from "./types"; // esbuild can't tell if SomeType is a value or type
// WITH isolatedModules: true - use explicit "export type"
export type { SomeType } from "./types"; // unambiguous: type-only, stripped at build time
// const enum is also banned under isolatedModules - use a regular enum or string union:
// const enum Direction { Up, Down } // Error under isolatedModules
enum Direction { Up = "UP", Down = "DOWN" } // OK: regular string enum
type DirectionLiteral = "UP" | "DOWN"; // Better: string literal unionIn production
import type prevents runtime artifacts when bundlers strip types - without it, a type-only import can become an empty side-effect import that confuses tree shaking and produces surprising output in module graphs. Set isolatedModules: true from day one: it's required by esbuild, swc, and most modern build tools, and catching the error early is cheaper than debugging a production build where a const enum silently expanded differently than expected. For path aliases, the paths in tsconfig.json only affect TypeScript's type resolution - configure your bundler (Next.js next.config.js, Vite resolve.alias) separately to make runtime imports work. The two configs must stay in sync.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free