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?

  1. Clarity — readers know this import is only for types
  2. Smaller bundles — bundlers can safely remove type-only imports
  3. Avoid circular dependencies — type-only imports don’t create runtime cycles
  4. Required by some toolsisolatedModules mode (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?

  1. Third-party JavaScript libraries that don’t include types
  2. Your own JavaScript files that you want to use with TypeScript
  3. 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:

  1. Types included in the package (types field in package.json)
  2. @types/ packages in node_modules/@types/
  3. 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:

StrategyWhen to Use
node16 / nodenextNode.js projects (ESM or CommonJS)
bundlerProjects 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.