In the previous tutorial, we learned about classes and access modifiers. Now let’s learn about modules — how TypeScript organizes code across multiple files.
By the end of this tutorial, you will know how to use import and export, type-only imports, declaration files, path aliases, and third-party type packages.
What Are Modules?
A module is a file that exports values, functions, types, or classes. Other files can import what they need. This keeps your code organized and avoids name conflicts.
In TypeScript, any file with a top-level import or export is a module. Files without them are global scripts.
// user.ts — this is a module because it has an export
export interface User {
id: number;
name: string;
email: string;
}
export function createUser(name: string, email: string): User {
return { id: Math.random(), name, email };
}
// main.ts — imports from user.ts
import { User, createUser } from "./user";
const user: User = createUser("Alex", "alex@example.com");
console.log(user.name); // "Alex"
Named Exports vs Default Exports
Named Exports
Named exports let you export multiple values from one file. You import them by exact name:
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export const PI = 3.14159;
// main.ts
import { add, multiply, PI } from "./math";
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log(PI); // 3.14159
Default Exports
A default export is the “main” thing a file exports. Each file can have only one default export:
// logger.ts
export default class Logger {
log(message: string): void {
console.log(`[LOG] ${message}`);
}
}
// main.ts — you choose the import name
import Logger from "./logger";
const logger = new Logger();
logger.log("Hello"); // [LOG] Hello
Which One to Use?
Named exports are better in most cases:
- They force consistent names across your codebase
- They work well with auto-import in VS Code
- Refactoring is easier — renaming an export updates all imports
Default exports are common in React components (one component per file) and configuration files. But many teams prefer named exports everywhere.
Renaming Imports
If two modules export the same name, rename one with as:
import { User as AdminUser } from "./admin";
import { User as RegularUser } from "./customer";
const admin: AdminUser = { role: "admin", name: "Sam" };
const customer: RegularUser = { tier: "free", name: "Alex" };
Re-Exports
Re-exports let you create a single entry point for a folder:
// models/user.ts
export interface User {
id: number;
name: string;
}
// models/product.ts
export interface Product {
id: number;
title: string;
price: number;
}
// models/index.ts — re-export everything
export { User } from "./user";
export { Product } from "./product";
Now other files import from one place:
import { User, Product } from "./models";
You can also rename during re-export:
export { User as AppUser } from "./user";
Type-Only Imports
Sometimes you import a type only for type-checking. It disappears at runtime. TypeScript has a special syntax for this:
import type { User } from "./user";
// This works for type annotations
function greet(user: User): string {
return `Hello, ${user.name}`;
}
// This would NOT work — User does not exist at runtime
// const user = new User(); // Error
Type-only imports make your intention clear. They also help bundlers remove unused code (tree-shaking).
You can also use inline type imports:
import { createUser, type User } from "./user";
// createUser is a real value — imported normally
// User is only a type — removed at runtime
Why Use Type-Only Imports?
- Clarity — readers know this import is only for types
- Smaller bundles — bundlers can safely remove type-only imports
- Avoid circular dependencies — type-only imports don’t create runtime cycles
- Required by some tools —
isolatedModulesmode (used by Babel, SWC, esbuild) requires it when importing types
Declaration Files (.d.ts)
Declaration files describe the types of JavaScript code without including any implementation. They end with .d.ts.
When Do You Need Them?
- Third-party JavaScript libraries that don’t include types
- Your own JavaScript files that you want to use with TypeScript
- Publishing a TypeScript library as JavaScript with type information
Example
Imagine a JavaScript library string-utils.js:
// string-utils.js
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
module.exports = { capitalize };
You create a declaration file:
// string-utils.d.ts
export function capitalize(str: string): string;
Now TypeScript knows the types of capitalize without any changes to the JavaScript file.
Generating Declaration Files
If you write TypeScript, you can generate .d.ts files automatically:
{
"compilerOptions": {
"declaration": true,
"outDir": "./dist"
}
}
Running npx tsc creates .js files and matching .d.ts files in the dist folder.
Third-Party Types: @types Packages
Many JavaScript libraries don’t include TypeScript types. The community maintains types in a project called DefinitelyTyped. These types are published as @types/ packages on npm.
# Install Express and its types
npm install express
npm install --save-dev @types/express
# Install Lodash and its types
npm install lodash
npm install --save-dev @types/lodash
After installing, TypeScript finds the types automatically:
import express from "express";
const app = express(); // TypeScript knows this is an Express Application
app.get("/", (req, res) => {
res.send("Hello"); // req and res are fully typed
});
How TypeScript Finds Types
TypeScript looks for types in this order:
- Types included in the package (
typesfield in package.json) @types/packages innode_modules/@types/- Declaration files you provide
Most modern libraries (Zod, tRPC, Prisma, Drizzle) ship their own types. You only need @types/ for older JavaScript libraries.
Path Aliases
As your project grows, imports become long and ugly:
import { User } from "../../../models/user";
import { validate } from "../../../utils/validate";
Path aliases fix this. Add paths and baseUrl to your tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
Now you can import like this:
import { User } from "@/models/user";
import { validate } from "@/utils/validate";
Important: Path aliases only tell TypeScript where to look. Your bundler (Vite, webpack, esbuild) or runtime also needs to understand these aliases. Most tools support this out of the box or with a small plugin.
Module Resolution
Module resolution is how TypeScript finds the file behind an import. There are several strategies:
| Strategy | When to Use |
|---|---|
node16 / nodenext | Node.js projects (ESM or CommonJS) |
bundler | Projects using Vite, webpack, esbuild, or Next.js |
Set it in tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}
For most modern projects, bundler is the right choice. It supports path aliases, .ts extensions in imports, and works with all major build tools.
Namespaces — A Legacy Feature
Before ES modules became the standard, TypeScript had namespaces (originally called “internal modules”):
namespace Validation {
export function isEmail(value: string): boolean {
return value.includes("@");
}
export function isNotEmpty(value: string): boolean {
return value.length > 0;
}
}
// Usage
Validation.isEmail("alex@example.com"); // true
You should not use namespaces in new code. ES modules (import/export) replaced them entirely. You might still see namespaces in older codebases or in .d.ts files for global libraries.
The only place namespaces are still useful is declaration merging — adding types to existing libraries:
// Extending Express Request type
declare namespace Express {
interface Request {
user?: { id: string; name: string };
}
}
Organizing a Real Project
Here is a common folder structure for a TypeScript project:
src/
models/
user.ts
product.ts
index.ts # Re-exports all models
services/
auth.ts
product.ts
index.ts # Re-exports all services
utils/
validate.ts
format.ts
index.ts # Re-exports all utils
main.ts # Entry point
Each index.ts re-exports everything from its folder:
// src/models/index.ts
export { User } from "./user";
export { Product } from "./product";
export type { UserRole } from "./user"; // type-only re-export
Then your main code imports from clean paths:
// src/main.ts
import { User, Product } from "./models";
import { authenticate } from "./services";
import { validateEmail } from "./utils";
Common Mistakes
Mistake 1: Forgetting File Extensions in Node.js
When using "module": "node16" or "nodenext", Node.js requires file extensions:
// Wrong — Node.js ESM needs extensions
import { User } from "./user";
// Correct
import { User } from "./user.js"; // Yes, .js even for .ts files
This is because TypeScript does not rewrite import paths. The .js extension tells Node.js where to find the compiled file.
Mistake 2: Circular Imports
If a.ts imports from b.ts and b.ts imports from a.ts, you have a circular dependency. This can cause runtime errors where imported values are undefined.
Fix it by:
- Moving shared types to a separate file
- Using type-only imports for types (they don’t create runtime dependencies)
- Restructuring your modules
Mistake 3: Using require() in TypeScript
Avoid require() in TypeScript. Use ES module import syntax instead:
// Avoid
const express = require("express");
// Prefer
import express from "express";
What’s Next?
In the next tutorial, we will learn about utility types — built-in types like Partial, Required, Pick, Omit, and Record that transform other types.