When a project is tiny, keeping everything in one file feels fine. But the moment you add a second feature, a third helper, and a fourth type, that single file turns into a wall of code nobody enjoys scrolling through. Modules are the tidy answer to that mess. A module is simply a file that decides what to share with the outside world and what to keep private, and TypeScript gives you clear, predictable tools to wire those files together.
In this guide we’ll walk through how to organize TypeScript code across multiple files using ES modules. We’ll cover exporting and importing, the difference between exporting values and exporting types, the special import type syntax, named versus default exports, and a plain-language tour of how TypeScript figures out where a module actually lives. No prior module experience required.
What a module actually is
Here is the rule that explains almost everything: in TypeScript, any file that has at least one top-level import or export is a module. That’s it. A module gets its own private scope, which means the variables and functions you declare inside it are invisible to other files unless you deliberately hand them out with export.
Think of a module like a small shop. Most of what happens behind the counter stays behind the counter. The shop chooses a few items to put in the window, and those window items are the things other files can come and pick up. The export keyword is how you put something in the window.
// user.ts
export interface User {
id: number;
name: string;
email: string;
}
export function greet(user: User): string {
return `Hello, ${user.name}!`;
}
// This helper is NOT exported, so it stays private to this file.
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
In the example above, User and greet are visible to the rest of the app. The normalizeEmail helper is private. Another file can use User and greet, but it has no idea normalizeEmail even exists.
Module vs script
A file with no top-level import or export is treated as a plain script, and its variables leak into the global scope. Adding even a single export turns the file into a proper module with its own scope. When in doubt, export something.
Named exports and how to import them
The style we used above is called a named export. Each thing you export has a name, and other files pull those names in by listing them inside curly braces.
// app.ts
import { User, greet } from "./user";
const jane: User = {
id: 1,
name: "Jane Doe",
email: "jane@example.com",
};
console.log(greet(jane)); // "Hello, Jane Doe!"
A few things worth noticing. The path "./user" is relative to the current file, and you don’t write the .ts extension. You import only the names you actually need, which keeps things clear and lets tooling drop unused code later. And the names on both sides must match, because that’s how TypeScript links them up.
If a name is too generic or clashes with something you already have, you can rename it as you import using as:
import { greet as greetUser } from "./user";
You can also collect everything a module exports into a single object, which is handy when a file exports many small utilities:
// Import every named export under one namespace.
import * as userModule from "./user";
const bob = { id: 2, name: "Bob", email: "bob@example.com" };
console.log(userModule.greet(bob));
Default exports
A module can also nominate one default export: the single “main thing” that file is about. You write export default, and the importing file gets to choose any name it likes, with no curly braces.
// logger.ts
export default function logger(message: string): void {
console.log(`[LOG] ${message}`);
}
// app.ts
import log from "./logger"; // any name works here
log("Server started");
Default and named exports can live in the same file. A common pattern is a default export for the headline feature plus a few named exports for related helpers.
// api.ts
export default function fetchUser(id: number) {
/* ... */
}
export const BASE_URL = "https://api.example.com";
export const TIMEOUT = 5000;
import fetchUser, { BASE_URL, TIMEOUT } from "./api";
So which should you reach for? Here is a quick comparison.
| Aspect | Named export | Default export |
|---|---|---|
| How many per file | Many | One |
| Import name | Must match (or use as) |
You pick freely |
| Rename clarity | Consistent everywhere | Can drift between files |
| Refactor/autocomplete | Easier for tools | Slightly looser |
A reasonable default
Many teams prefer named exports almost everywhere. Because the names are fixed, autocomplete, renaming tools, and “find all usages” work more reliably. Default exports are perfectly fine, but the consistency of named exports tends to pay off as a codebase grows.
Exporting types versus exporting values
Here’s where TypeScript adds something JavaScript doesn’t have. You don’t only export functions and variables. You also export types such as interface, type aliases, and enum. The neat part is that the same import/export syntax handles both.
// shapes.ts
export type ID = string | number;
export interface Product {
id: ID;
title: string;
price: number;
}
export function formatPrice(p: Product): string {
return `$${p.price.toFixed(2)}`;
}
// cart.ts
import { Product, ID, formatPrice } from "./shapes";
function addToCart(id: ID, product: Product) {
console.log(formatPrice(product));
}
There’s an important mental model here. Types live only at compile time. When TypeScript turns your code into JavaScript, every type annotation, interface, and type-only import simply disappears. The browser never sees Product. A value like formatPrice, on the other hand, is real code that survives into the final JavaScript and runs at the end of the day.
The import type syntax
Because types vanish during compilation, TypeScript offers a way to say “I’m importing this purely as a type” using import type. This makes your intent explicit and guarantees the import contributes zero JavaScript to the output.
import type { Product } from "./shapes";
import { formatPrice } from "./shapes";
function describe(product: Product): string {
return formatPrice(product);
}
You can also mark individual names inline with the type modifier, which keeps everything on one line:
import { type Product, formatPrice } from "./shapes";
Why bother? Two reasons. First, it documents the difference clearly: anyone reading the file instantly sees which imports are just shapes and which are runnable code. Second, it removes any ambiguity for the compiler and bundlers about whether an import has a runtime cost, which avoids a class of subtle build issues.
Types are erased, not optional
Using a normal import for a type also works, so import type is a clarity-and-safety choice rather than a hard requirement. But once a project turns on stricter settings around type-only imports, being explicit with import type saves you from confusing errors. It’s a good habit to start early.
There’s a matching export type as well, for when you want to re-share a type without dragging any runtime code along:
export type { Product, ID } from "./shapes";
Re-exporting to build a clean public API
As folders fill up, importing from a dozen deep paths gets noisy. A common tidy-up is a single “barrel” file that re-exports the useful pieces, so the rest of the app imports from one friendly location.
// models/index.ts
export { greet } from "./user";
export type { User } from "./user";
export type { Product, ID } from "./shapes";
export { formatPrice } from "./shapes";
// somewhere else
import { greet, formatPrice } from "./models";
import type { User, Product } from "./models";
The folder now has one clear front door. Callers don’t need to know the internal file layout, and you’re free to rearrange files behind the barrel without breaking everyone’s imports.
How TypeScript resolves a module
When TypeScript sees import { greet } from "./user", it has to figure out which actual file that points to. The process is called module resolution, and you can picture it in two broad cases.
import "./user" -> RELATIVE: look right next to this file
import "lodash" -> BARE: look inside node_modules
Relative imports start with ./ or ../. They are resolved against the current file’s own folder, much like walking through directories. ./user means “the user file sitting beside me,” and ../shared/config means “go up one level, then into shared.” TypeScript tries sensible candidates such as user.ts, user.tsx, user.d.ts, and a folder named user with an index file inside.
Bare imports have no leading dot, like import express from "express". These are package names. TypeScript looks for them in node_modules, and for type information it checks the package’s bundled types or a matching @types/... package.
You don’t normally control this by hand, file by file. Instead, a project’s tsconfig.json holds a few settings that steer resolution, like which module system to emit and how aggressively to search. The exact flag names shift between TypeScript versions, so the safe takeaway is conceptual: relative paths point at your own files, bare names point at installed packages, and tsconfig.json is the dial that tunes the details. When an import can’t be found, the first things to check are the spelling of the path and whether the package (and its types) is actually installed.
If you already know JavaScript modules
TypeScript’s import/export is the same ES module system you may have met in JavaScript, with type support layered on top. If you’ve used modules in JS, almost everything here will feel familiar, and the only genuinely new pieces are exporting types and the import type syntax.
Recap
Modules let you break a TypeScript project into focused files that each decide what to share. The essentials:
- A file becomes a module the moment it has a top-level
importorexport, and everything inside it stays private until exported. - Named exports carry fixed names and tend to play best with tooling; a default export is the one “main thing” per file and can be imported under any name.
- TypeScript exports types (interfaces, type aliases, enums) with the same syntax as values, but types are erased at compile time while values become real JavaScript.
import typeandexport typemake type-only imports explicit, documenting intent and guaranteeing no runtime cost.- Barrel files re-export a folder’s useful pieces behind one clean import path.
- Module resolution boils down to: relative paths (
./) point at your own files, bare names point at packages innode_modules, andtsconfig.jsontunes the details.
Start by exporting only what other files truly need, lean on named exports for consistency, and reach for import type when you’re pulling in a shape rather than runnable code. With those habits, even a large TypeScript codebase stays approachable.