Any real program is split across files. TypeScript uses the standard ES module system (import / export) and adds a few features of its own: type-only imports, compiler options that decide how module specifiers are resolved, and the older namespace construct. After this lesson you will know how to structure a multi-file project, how to keep type imports from leaking into runtime output, and when (rarely) a namespace is still appropriate.
A file that contains at least one top-level import or export is a module: its declarations are private unless exported. A file with neither is a script whose declarations are global, which causes surprising "Duplicate identifier" errors. Add export {}; to make an otherwise empty file a module.
// math.ts
export const PI = 3.14159;
export function area(r: number) { return PI * r * r; }
export default function describe() { return "circle helpers"; }
// app.ts
import describe, { PI, area as circleArea } from "./math.js";
import * as math from "./math.js"; // namespace import
export { PI } from "./math.js"; // re-export
export * from "./math.js"; // re-export everything namedNamed exports are preferred for most code: they refactor safely, tree-shake well and autocomplete reliably. Default exports are common for a file's single main thing, such as a React component.
Types disappear after compilation, so importing one should not produce a runtime import statement. import type and export type make this explicit, and the inline form marks individual names:
import type { User, Role } from "./models.js"; // whole statement is type-only
import { createUser, type Permission } from "./auth.js"; // mixed
export type { User } from "./models.js";Two compiler options govern this:
| Option | Effect |
|---|---|
| isolatedModules | Requires code that single-file transpilers (esbuild, swc, Babel, Vite) can compile safely; re-exporting a type needs export type |
| verbatimModuleSyntax | Emits imports exactly as written; anything not marked type is kept, so a type imported without type is an error |
Modern projects enable both. They guarantee that removing a type import never changes runtime behaviour.
module and moduleResolution in tsconfig.json determine how "./math.js" is found and what the output looks like:
"module": "nodenext" (with "moduleResolution": "nodenext") follows Node.js rules: relative imports need the output extension (./math.js even though the source is math.ts), and package.json "type": "module" selects ESM vs CommonJS."module": "esnext" + "moduleResolution": "bundler" is for Vite, webpack and similar tools: extensionless imports and exports maps work, and the bundler produces the final output."module": "commonjs" emits require() calls for legacy Node.js projects; pair it with esModuleInterop: true so import fs from "fs" behaves.TypeScript 5.7 added rewriteRelativeImportExtensions, which lets you write ./math.ts in source and have tsc rewrite it to .js in the output; Node.js 22.18+ and 24 can run such .ts files directly by stripping types.
Dynamic import() returns a Promise of the module namespace and is fully typed:
const { area } = await import("./math.js");Before ES modules existed, TypeScript grouped code with namespace (originally "internal modules"). A namespace is compiled to an IIFE and an object:
namespace Validation {
export const emailRe = /^[^@\s]+@[^@\s]+$/;
export function isEmail(s: string) { return emailRe.test(s); }
}
Validation.isEmail("ada@example.com");Namespaces can be nested, split across files and merged with a same-named function, class or enum, which some libraries use to attach helpers to a type. In application code they are considered legacy: use files and folders as your namespaces. They remain legitimate in declaration files (declare namespace Express { ... }) and for augmenting global libraries, topics covered in the declaration files lesson.
The erasableSyntaxOnly option (TypeScript 5.8) rejects namespaces that contain runtime code, because Node.js type stripping cannot execute them; it is another push toward plain ES modules.
.js extension under nodenext and getting "Relative import paths need explicit file extensions".type under verbatimModuleSyntax, which errors because the import would be retained at runtime.What makes a TypeScript file a module rather than a script?
import or export is a module with private scope; use export {} to force module scope.import type / export type for anything that is only a type.isolatedModules and verbatimModuleSyntax so type imports can never leak into runtime output.module: nodenext needs .js extensions on relative imports; moduleResolution: bundler suits Vite and webpack.Next lesson: tsconfig.json and Compiler Options — configure targets, strictness, output and module settings for a project.