TypeScript Blitz Day 9: Module System & Namespaces

Master TypeScript's module system - ES modules, import/export patterns, barrel exports, type-only imports, module resolution, and modern code organization for scalable applications.

12 min read

Goal

After this lesson you’ll organize code in a scalable way using ES modules and understand modern module patterns.


Theory Part (20 min)

1. ES Modules - import/export Basics

// user.ts - Named exports
export interface User {
  id: number;
  name: string;
  email: string;
}

export function createUser(name: string, email: string): User {
  return {
    id: Math.random(),
    name,
    email
  };
}

export const MAX_USERS = 1000;

// main.ts - Named imports
import { User, createUser, MAX_USERS } from './user';

const user: User = createUser("John", "john@example.com");
console.log(MAX_USERS);

2. Default vs Named Exports

// logger.ts - Default export (one per file)
export default class Logger {
  log(message: string): void {
    console.log(message);
  }
}

// Or function
export default function log(message: string): void {
  console.log(message);
}

// Import default - can use any name
import Logger from './logger';
import MyLogger from './logger'; // Same thing, different name
import log from './logger';

// Named exports (multiple per file)
// utils.ts
export function formatDate(date: Date): string {
  return date.toISOString();
}

export function formatNumber(num: number): string {
  return num.toFixed(2);
}

// Import named - must use exact names
import { formatDate, formatNumber } from './utils';

// Can rename with 'as'
import { formatDate as fd, formatNumber as fn } from './utils';

// Import all as namespace
import * as Utils from './utils';
Utils.formatDate(new Date());

// Mix default and named
// api.ts
export default class ApiClient {}
export const API_URL = "https://api.example.com";

// Import both
import ApiClient, { API_URL } from './api';

When to use:

  • Default export: Main class/function in file, library entry point
  • Named exports: Multiple related exports, better for tree-shaking

3. Re-exporting and Barrel Exports

// types.ts
export interface User {
  id: number;
  name: string;
}

export interface Post {
  id: number;
  title: string;
}

// services.ts
export class UserService {}
export class PostService {}

// index.ts - Barrel export (aggregates exports)
export * from './types';
export * from './services';
export { default as ApiClient } from './api';

// Now can import from one place
import { User, Post, UserService, PostService, ApiClient } from './index';
// Instead of multiple imports:
// import { User, Post } from './types';
// import { UserService, PostService } from './services';
// import ApiClient from './api';

// Selective re-export
export { User, Post } from './types'; // Only these, not all

// Rename on re-export
export { UserService as US } from './services';

4. Type-only Imports

// types.ts
export interface User {
  id: number;
  name: string;
}

export function createUser(): User {
  return { id: 1, name: "John" };
}

// Import only the type (erased in JS)
import type { User } from './types';

// This won't be in compiled JS
const user: User = { id: 1, name: "Jane" };

// Import both type and value
import { createUser, type User } from './types';

// Why use 'import type'?
// 1. Makes it clear it's only for types
// 2. Helps bundlers with tree-shaking
// 3. Avoids circular dependency issues
// 4. Better compilation performance

// Export type-only
export type { User } from './types'; // Re-export only type

5. Module Resolution Strategies

// TypeScript has two main strategies: Classic and Node

// Classic (legacy, rarely used)
// import { x } from "./module"
// Looks for: ./module.ts, ./module.d.ts

// Node (default, mimics Node.js)
// import { x } from "./module"
// Looks for:
// 1. ./module.ts
// 2. ./module.tsx
// 3. ./module.d.ts
// 4. ./module/package.json (main field)
// 5. ./module/index.ts

// tsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "node", // or "classic"
    "baseUrl": "./src",
    "paths": {
      "@utils/*": ["utils/*"],
      "@components/*": ["components/*"]
    }
  }
}

// Now can import with aliases
import { formatDate } from '@utils/format';
import { Button } from '@components/Button';

// Instead of relative paths
import { formatDate } from '../../utils/format';

6. Namespaces (Legacy)

// Namespaces - old way to organize code (pre-modules)
namespace Utils {
  export function formatDate(date: Date): string {
    return date.toISOString();
  }
  
  export function formatNumber(num: number): string {
    return num.toFixed(2);
  }
  
  // Not exported - internal only
  function helper(): void {}
}

// Usage
Utils.formatDate(new Date());

// Nested namespaces
namespace Company {
  export namespace Employees {
    export class Manager {}
    export class Developer {}
  }
  
  export namespace Products {
    export class Software {}
  }
}

const dev = new Company.Employees.Developer();

// Why NOT to use namespaces:
// 1. ES modules are standard
// 2. Better tooling support for modules
// 3. Tree-shaking works better with modules
// 4. Can't code-split namespaces easily

// When you might see namespaces:
// - Legacy code
// - Ambient declarations
// - Global type definitions

7. Triple-slash Directives

// Triple-slash directives are compiler instructions

// Reference another file
/// <reference path="./types.d.ts" />

// Reference a library
/// <reference types="node" />

// Common use: declaration files
// globals.d.ts
/// <reference types="node" />

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string;
      API_KEY: string;
    }
  }
}

export {};

// Modern alternative: Use tsconfig.json "types" field
{
  "compilerOptions": {
    "types": ["node", "jest"]
  }
}

// When to use triple-slash:
// - Only in .d.ts files
// - Declaring dependencies between declaration files
// - Rare cases in modern TypeScript

8. Dynamic Imports

// Dynamic imports for code-splitting
async function loadModule() {
  // Import returns a Promise
  const module = await import('./heavy-module');
  module.doSomething();
}

// Conditional loading
if (condition) {
  const { SpecialFeature } = await import('./special-feature');
  new SpecialFeature();
}

// Type-safe dynamic imports
type ModuleType = typeof import('./module');

async function getModule(): Promise<ModuleType> {
  return import('./module');
}

// Lazy loading in React
const LazyComponent = React.lazy(() => import('./Component'));

Practice Part (25 min)

Exercise: Build a Modular User Management System

Create a well-organized module structure with proper imports/exports.

// TODO: Create the following file structure

// types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user";
}

export type UserId = number;
export type UserRole = User["role"];

// types/index.ts - Barrel export
// TODO: Re-export all from user.ts

// services/user-service.ts
// TODO: Create UserService class with:
// - getUser(id: UserId): Promise<User>
// - createUser(data: Omit<User, "id">): Promise<User>
// - updateUser(id: UserId, data: Partial<User>): Promise<User>

// services/auth-service.ts
// TODO: Create AuthService with:
// - login(email: string, password: string): Promise<User>
// - logout(): void
// - getCurrentUser(): User | null

// services/index.ts
// TODO: Barrel export both services

// utils/validators.ts
// TODO: Create validation functions:
// - isValidEmail(email: string): boolean
// - isValidRole(role: string): role is UserRole

// utils/formatters.ts
// TODO: Create formatter functions:
// - formatUserName(user: User): string
// - formatRole(role: UserRole): string

// utils/index.ts
// TODO: Barrel export with renamed exports

// index.ts - Main entry point
// TODO: Export everything from types, services, and utils
// Use proper re-exports

// main.ts - Usage example
// TODO: Import and use the modules

Solution:

Click to see solution
// types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user";
}

export type UserId = number;
export type UserRole = User["role"];

// types/index.ts
export * from './user';

// services/user-service.ts
import type { User, UserId } from '../types';

export class UserService {
  private users: Map<UserId, User> = new Map();
  private nextId: number = 1;
  
  async getUser(id: UserId): Promise<User> {
    const user = this.users.get(id);
    if (!user) {
      throw new Error("User not found");
    }
    return user;
  }
  
  async createUser(data: Omit<User, "id">): Promise<User> {
    const user: User = {
      id: this.nextId++,
      ...data
    };
    this.users.set(user.id, user);
    return user;
  }
  
  async updateUser(id: UserId, data: Partial<User>): Promise<User> {
    const user = await this.getUser(id);
    const updated = { ...user, ...data };
    this.users.set(id, updated);
    return updated;
  }
}

// services/auth-service.ts
import type { User } from '../types';

export class AuthService {
  private currentUser: User | null = null;
  
  async login(email: string, password: string): Promise<User> {
    // Simplified - in real app would validate credentials
    const user: User = {
      id: 1,
      name: "John Doe",
      email,
      role: "user"
    };
    this.currentUser = user;
    return user;
  }
  
  logout(): void {
    this.currentUser = null;
  }
  
  getCurrentUser(): User | null {
    return this.currentUser;
  }
}

// services/index.ts
export { UserService } from './user-service';
export { AuthService } from './auth-service';

// utils/validators.ts
import type { UserRole } from '../types';

export function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

export function isValidRole(role: string): role is UserRole {
  return role === "admin" || role === "user";
}

// utils/formatters.ts
import type { User, UserRole } from '../types';

export function formatUserName(user: User): string {
  return `${user.name} (${user.email})`;
}

export function formatRole(role: UserRole): string {
  return role.charAt(0).toUpperCase() + role.slice(1);
}

// utils/index.ts
export {
  isValidEmail as validateEmail,
  isValidRole as validateRole
} from './validators';

export {
  formatUserName as formatUser,
  formatRole
} from './formatters';

// index.ts - Main entry point
export * from './types';
export * from './services';
export * from './utils';

// main.ts - Usage
import {
  User,
  UserService,
  AuthService,
  validateEmail,
  formatUser,
  formatRole
} from './index';

async function main() {
  const userService = new UserService();
  const authService = new AuthService();
  
  // Create user
  const newUser = await userService.createUser({
    name: "Jane Doe",
    email: "jane@example.com",
    role: "admin"
  });
  
  console.log("Created:", formatUser(newUser));
  console.log("Role:", formatRole(newUser.role));
  
  // Login
  if (validateEmail("jane@example.com")) {
    const user = await authService.login("jane@example.com", "password");
    console.log("Logged in:", user.name);
  }
  
  // Get current user
  const current = authService.getCurrentUser();
  console.log("Current user:", current?.name);
}

main();

Quick Review - Key Concepts

1. Default vs Named exports?

Default export:

  • One per file
  • Can rename on import
  • Good for main class/function
// logger.ts
export default class Logger {}

// Import with any name
import Logger from './logger';
import MyLogger from './logger';

Named exports:

  • Multiple per file
  • Must use exact name (or rename with ‘as’)
  • Better for tree-shaking
  • More explicit
// utils.ts
export function format() {}
export function parse() {}

// Must use exact names
import { format, parse } from './utils';
import { format as f } from './utils'; // Rename

2. What is a barrel export?

Barrel export is an index.ts file that re-exports from multiple files for convenience.

// index.ts (barrel)
export * from './types';
export * from './services';
export { default as Api } from './api';

// Single import instead of multiple
import { User, UserService, Api } from './index';

// Instead of:
import { User } from './types';
import { UserService } from './services';
import Api from './api';

Benefits:

  • Cleaner imports
  • Hide internal structure
  • Easy refactoring

Drawbacks:

  • Can hurt tree-shaking if not careful
  • May re-export unused code

3. What is type-only import?

Type-only import uses import type to import only types (erased in JS).

import type { User } from './types';

// Only for type annotation
const user: User = { id: 1, name: "John" };

// Won't be in compiled JavaScript

Benefits:

  • Makes intent clear
  • Better tree-shaking
  • Avoids circular dependencies
  • Faster compilation

Mixed imports:

// Import both
import { createUser, type User } from './types';

4. Namespaces vs Modules?

Modules (modern):

// file1.ts
export function doSomething() {}

// file2.ts
import { doSomething } from './file1';

Namespaces (legacy):

namespace Utils {
  export function doSomething() {}
}

Utils.doSomething();

Use modules, not namespaces:

  • ES standard
  • Better tooling
  • Tree-shaking
  • Code-splitting

5. When to use triple-slash directives?

Triple-slash directives are compiler instructions:

/// <reference types="node" />
/// <reference path="./types.d.ts" />

Use only in:

  • Declaration files (.d.ts)
  • Declaring dependencies
  • Legacy code

Modern alternative: Use tsconfig.json

{
  "compilerOptions": {
    "types": ["node", "jest"]
  }
}

Checklist

  • Use named exports for multiple exports
  • Use default export for main class/function
  • Create barrel exports for clean imports
  • Use type-only imports when appropriate
  • Configure module resolution in tsconfig
  • Avoid namespaces (use modules instead)
  • Understand when triple-slash directives are needed
  • Use dynamic imports for code-splitting

Quick Reference

// Named exports
export interface User {}
export function createUser() {}
export const MAX = 100;

// Default export
export default class Api {}

// Import named
import { User, createUser } from './module';

// Import default
import Api from './module';

// Import all
import * as Module from './module';

// Rename
import { User as U } from './module';

// Type-only
import type { User } from './module';

// Re-export (barrel)
export * from './types';
export { default as Api } from './api';

// Dynamic import
const module = await import('./module');

// Module resolution (tsconfig.json)
{
  "compilerOptions": {
    "moduleResolution": "node",
    "baseUrl": "./src",
    "paths": {
      "@utils/*": ["utils/*"]
    }
  }
}

// Path aliases
import { format } from '@utils/format';

Tomorrow: Async/Await & Promises - master asynchronous TypeScript

Back to blog