Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Modules & Imports: using/namespace vs. import/export

For .NET engineers who know: namespace, using directives, assembly references, and the way C# automatically shares all symbols within a namespace You’ll learn: How TypeScript’s ES module system works, why explicit imports of every symbol are required, and the practical patterns (barrel files, path aliases, dynamic imports) you will use daily Time: 12 min read


The .NET Way (What You Already Know)

In C#, code organization is layered. A namespace groups types logically — it is a naming scope, not a file boundary. A single namespace can span dozens of files across multiple assemblies, and a single file can contain multiple namespaces. The using directive at the top of a file brings that namespace’s symbols into scope for the rest of the file.

// UserService.cs — the namespace declaration tells the compiler where this type lives
namespace MyApp.Services;

using MyApp.Models;     // Bring in the Models namespace
using MyApp.Data;       // Bring in the Data namespace
using System.Threading; // Bring in a BCL namespace from the System assembly

public class UserService
{
    // 'User' and 'UserDto' are from MyApp.Models
    // 'AppDbContext' is from MyApp.Data
    // All available without any further import syntax
    public async Task<UserDto> GetUser(int id, AppDbContext db)
    {
        var user = await db.Users.FindAsync(id);
        return new UserDto(user);
    }
}

The critical behavior here: once you write using MyApp.Models;, every public type in that namespace is available. You do not import User and UserDto individually — you import the namespace and get everything in it. The linker resolves which assemblies contain those namespaces via project references in your .csproj.

Global usings (introduced in C# 10) take this further — a single global using MyApp.Models; makes a namespace available across every file in the project without any per-file declaration.

This “import a namespace, get everything in it” model is deeply ingrained in how .NET engineers think about code organization. TypeScript works completely differently.


The TypeScript Way

ES Modules: The Foundation

TypeScript uses the ES module system (ECMAScript Modules, or ESM). In this model, every file is its own module. A symbol (a function, class, interface, constant, or type) is private to its file by default. To make it visible to other files, you must explicitly export it. To use it in another file, you must explicitly import it.

There is no concept equivalent to “namespace” as a shared scope spanning multiple files. The file boundary is the module boundary.

// user.service.ts
// 'User' and 'UserDto' are not available just because they're in the same project.
// You must import each symbol explicitly.
import { User } from './models/user.model';
import { UserDto } from './models/user.dto';
import { AppDataSource } from '../data/app-data-source';

export class UserService {
    async getUser(id: number): Promise<UserDto> {
        const user = await AppDataSource.findOne(User, { where: { id } });
        return new UserDto(user);
    }
}

The C# side-by-side makes the contrast clear:

C#TypeScript
using MyApp.Models;import { User, UserDto } from './models';
Imports a namespace (all symbols)Imports named symbols individually
Resolution via assembly references in .csprojResolution via file paths or node_modules
Compiler resolves namespaces across the projectModule loader resolves by file path
global using makes symbols project-wideNo direct equivalent (barrel files come close)
Namespace can span multiple filesOne file = one module

Named Exports vs. Default Exports

TypeScript (inheriting from JavaScript’s module history) supports two export styles: named exports and default exports.

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

export type UserRole = 'admin' | 'member' | 'viewer';

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

// To import named exports, use braces and the exact name:
import { User, UserRole, createUser } from './user.model';

// You can alias if there's a naming conflict:
import { User as UserModel } from './user.model';
// --- Default export ---
// user.service.ts
export default class UserService {
    async getUser(id: number): Promise<User> { /* ... */ }
}

// To import a default export, no braces — and the name is up to the importer:
import UserService from './user.service';
import MyUserService from './user.service'; // Same module, different local name — works

We use named exports in all project code. Default exports exist and you will encounter them in older codebases, React component files (some conventions use them), and third-party libraries — but they introduce friction:

  • IDEs and refactoring tools handle named exports more reliably
  • export default allows the importer to use any name, which makes grep-based search for usages harder
  • A file with a default export provides no signal about what it contains until you open it
  • Auto-import tools in VS Code work more predictably with named exports

The practical rule: always use named exports in your own code. When consuming libraries that use default exports, import them normally — you cannot change the library.

// Prefer this:
export class UserService { /* ... */ }
export interface UserDto { /* ... */ }

// Not this:
export default class UserService { /* ... */ }

Barrel Files (index.ts)

With named exports and explicit per-symbol imports, import paths can become verbose. A deeply nested module might require:

import { UserService } from '../../services/users/user.service';
import { UserDto } from '../../models/users/user.dto';
import { CreateUserInput } from '../../models/users/create-user.input';

Barrel files solve this. A barrel is an index.ts file that re-exports symbols from the files in its directory, providing a single clean entry point for the module.

// src/users/index.ts — the barrel file
export { UserService } from './user.service';
export { UserDto } from './user.dto';
export { CreateUserInput } from './create-user.input';
export type { UserRole } from './user.model';

Now all consumers import from the directory instead of individual files:

// Before barrel:
import { UserService } from '../../services/users/user.service';
import { UserDto } from '../../models/users/user.dto';

// After barrel:
import { UserService, UserDto } from '../../services/users';
// Node's module resolver sees '../../services/users', looks for index.ts, finds it

This is the closest TypeScript analog to the C# using MyApp.Services; pattern. The barrel defines the public API of the directory module, and consumers import from that boundary rather than from internal files.

Barrel file gotcha: Barrel files can introduce circular dependency issues and can bloat bundle sizes if a bundler cannot tree-shake effectively. Keep barrel files at meaningful architectural boundaries (a feature module, a shared utilities package) rather than creating one for every directory. See the Gotchas section for more detail.

Path Aliases

Even with barrel files, relative paths from deep files (../../../shared/utils) are fragile — moving a file breaks every import that referenced it by relative path. Path aliases solve this by letting you define short, absolute-looking import paths.

In tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@shared/*": ["src/shared/*"],
      "@features/*": ["src/features/*"]
    }
  }
}

Now any file in the project can import using the alias:

// Instead of this (relative, fragile):
import { UserService } from '../../../features/users/user.service';

// Use this (aliased, stable):
import { UserService } from '@features/users/user.service';
import { formatDate } from '@shared/utils/date';

Path aliases are a TypeScript compiler feature — they tell tsc how to resolve imports. However, the compiled JavaScript still needs a runtime that understands these aliases. Depending on your toolchain:

  • NestJS (Node.js): Use tsconfig-paths package and register it at startup, or use the --paths support in ts-node
  • Next.js: Next.js reads tsconfig.json paths automatically and configures its webpack/Turbopack accordingly
  • Vitest: Configure resolve.alias in vitest.config.ts to match your tsconfig.json paths
  • Vite: Same as Vitest (same config structure)

The @/ prefix is a convention (not a requirement) for the project root alias. You will see it in virtually every Next.js and NestJS project.

// The @ is just a convention — it signals "this is an alias, not a relative path"
import { Button } from '@/components/ui/button';
import { db } from '@/lib/db';
import { authOptions } from '@/lib/auth';

CommonJS vs. ESM: The Legacy Situation

This is the part of the module story that no one enjoys explaining. TypeScript’s type system is clean and consistent. The underlying JavaScript module system is not — it has two incompatible formats with a messy coexistence story.

CommonJS (CJS) is Node.js’s original module format, designed in 2009:

// CommonJS (you will see this in older Node.js code and many npm packages)
const express = require('express');
const { Router } = require('express');

module.exports = { UserService };
module.exports.UserService = UserService;

ES Modules (ESM) is the standard format defined in ES2015 (ES6), now the universal standard:

// ESM (what TypeScript compiles to when targeting modern environments)
import express from 'express';
import { Router } from 'express';

export { UserService };
export default UserService;

The problem: Node.js supported CJS for years before ESM stabilized. Many npm packages still ship CJS-only. When you import a CJS package from ESM code, Node.js has an interop layer, but it has edge cases. When TypeScript compiles your code, it can target either format depending on your tsconfig.json’s module setting.

Where we are now (2026):

SettingWhen to use
"module": "ESNext"Next.js, Vite-based projects, modern frontend
"module": "CommonJS"NestJS (default), older Node.js projects
"module": "NodeNext"New Node.js projects that want native ESM with full Node.js compatibility

For most practical work, you will not think about this directly — NestJS defaults to CJS, Next.js handles ESM automatically, and the frameworks abstract the difference. You will care about it when:

  1. A package you want to use ships ESM-only and your NestJS project targets CJS (fix: use dynamic import() or find a CJS version)
  2. You see ERR_REQUIRE_ESM in your Node.js logs (a CJS require() tried to load an ESM-only package)
  3. You are configuring a new project from scratch and must choose the right tsconfig.json module setting

The signal in any package’s documentation: look for "type": "module" in package.json (ESM-only) or the presence of separate .cjs and .mjs file extensions (dual-mode package).

Dynamic Imports

Standard import statements are static — they execute at module load time and are resolved before any code runs. TypeScript also supports dynamic imports via import(), which returns a Promise and defers loading until runtime.

// Static import — resolved at module load time
import { HeavyLibrary } from 'heavy-library';

// Dynamic import — deferred until this code path executes
async function generateReport(): Promise<void> {
    // Only load the PDF library if this function is actually called
    const { PDFDocument } = await import('pdf-lib');
    const doc = await PDFDocument.create();
    // ...
}

This is the equivalent of lazy loading an assembly in .NET — you defer the cost until the code is needed.

Common uses:

// Conditional loading based on environment
const logger = process.env.NODE_ENV === 'production'
    ? await import('./loggers/structured-logger')
    : await import('./loggers/console-logger');

// Route-level code splitting in Next.js (handled automatically by the framework,
// but you can trigger it manually with dynamic())
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./components/HeavyChart'), {
    loading: () => <Skeleton />,
    ssr: false, // Don't render on the server
});

// Plugin-style architecture
async function loadPlugin(name: string) {
    const plugin = await import(`./plugins/${name}`);
    return plugin.default;
}

Dynamic imports are also the standard solution for loading ESM-only packages from a CJS module:

// In a CJS NestJS module, importing an ESM-only package
async function useEsmOnlyPackage() {
    const { someFunction } = await import('esm-only-package');
    return someFunction();
}

How Module Resolution Works

When TypeScript (and Node.js at runtime) sees import { UserService } from './users', it needs to find the actual file. The resolution algorithm checks, in order:

  1. ./users.ts
  2. ./users.tsx
  3. ./users/index.ts
  4. ./users/index.tsx
  5. (For non-relative imports) node_modules/users/...

For non-relative imports like import { Injectable } from '@nestjs/common', the resolver looks in node_modules starting from the importing file’s directory and walking up until it finds the package.

The moduleResolution setting in tsconfig.json controls the exact algorithm:

SettingBehavior
"moduleResolution": "node"Classic Node.js resolution. Works for CJS. Common default.
"moduleResolution": "bundler"For projects using a bundler (Vite, Webpack). More permissive.
"moduleResolution": "NodeNext"Strict ESM-compatible resolution. Requires explicit extensions in imports.

For most NestJS projects, "node" is correct. For Next.js and Vite projects, "bundler" is what the framework sets. You will rarely need to change these defaults.

Circular Dependencies

Circular dependencies occur when module A imports from module B, and module B imports from module A. TypeScript will often not error on circular imports — the code might even appear to work. But at runtime, one of the modules will receive undefined for the imported symbol because the circular module was not fully initialized when the import was evaluated.

// --- PROBLEMATIC circular dependency ---

// user.service.ts
import { OrderService } from './order.service'; // A imports B

export class UserService {
    constructor(private orderService: OrderService) {}
}

// order.service.ts
import { UserService } from './user.service'; // B imports A

export class OrderService {
    constructor(private userService: UserService) {}
}

At startup, one of these will receive undefined as its import. The symptoms are obscure runtime errors that do not match what the types say.

The fix is architectural: extract shared types or logic into a third module that both depend on.

// shared-types.ts — no dependencies on user or order services
export interface UserSummary { id: number; name: string; }
export interface OrderSummary { id: number; userId: number; total: number; }

// user.service.ts — depends only on shared types
import { UserSummary } from './shared-types';
export class UserService { /* ... */ }

// order.service.ts — depends only on shared types
import { OrderSummary, UserSummary } from './shared-types';
export class OrderService { /* ... */ }

In NestJS specifically, the framework’s DI container can help with true circular service dependencies using forwardRef(), but this is a code smell — circular service dependencies almost always indicate a domain modeling problem.


Key Differences

ConceptC#TypeScript
Organizing codenamespace — logical grouping, spans filesModule file — one file = one module boundary
Importing symbolsusing MyApp.Models; brings all symbolsimport { User, UserDto } from './models' — explicit per-symbol
Default visibilityAll public types in a namespace accessible with usingAll symbols private by default; export makes them visible
Project-wide importsglobal usingNo direct equivalent; barrel files reduce per-file imports
Shorter import pathsN/A (namespace hierarchy is the path)Path aliases in tsconfig.json (@/src/...)
Lazy loadingAssembly lazy loading or MEFimport() dynamic import returns a Promise
Circular referencesCompiler error for ambiguous references; generally preventedTypeScript may not error; runtime undefined is the symptom
Module formatSingle managed code format (IL)Two formats: CJS and ESM; interop layer between them

Gotchas for .NET Engineers

1. Everything in a Namespace Is Not Automatically Available

This is the most common mental model error .NET engineers make when starting with TypeScript. In C#, if User and UserService are both in MyApp.Services, and you write using MyApp.Services;, both are available. You do not think about this — it is invisible.

In TypeScript, if User is defined in user.model.ts and UserService is defined in user.service.ts, they know nothing about each other until you write an explicit import. Being in the same directory, having similar names, or being part of the same logical feature — none of this creates any automatic relationship.

// This will not work — TypeScript will error with "Cannot find name 'User'"
// even if user.model.ts is in the same directory
export class UserService {
    getUser(id: number): User { // ERROR: User is not imported
        // ...
    }
}

// This is required:
import { User } from './user.model'; // Explicit import of every symbol used

export class UserService {
    getUser(id: number): User { // Works
        // ...
    }
}

The consequence for productivity: you will spend time adding imports, especially early on. VS Code’s TypeScript language server auto-import helps significantly — when you type a symbol name it recognizes, it offers to add the import automatically (Ctrl+. or the lightbulb icon). Enable “Auto Import Suggestions” in VS Code settings and use it constantly.

2. Barrel Files Can Silently Break Tree-Shaking and Create Initialization Ordering Bugs

Barrel files look like a clean solution to verbose imports, but they have two production-grade issues.

Tree-shaking degradation: When a bundler (webpack, Rollup, Vite) processes a barrel file, it must include any module that is re-exported, even if the consumer only uses one symbol. If your barrel re-exports from 20 files and a page only uses one symbol, a poorly configured bundler may include all 20 modules in the bundle. Modern bundlers with ESM and proper "sideEffects": false in package.json handle this correctly, but it requires verification.

Initialization ordering bugs: When barrel files create circular re-export chains, modules may initialize in an unexpected order, causing undefined at runtime. This is especially tricky in NestJS module definitions where providers import from barrels that eventually re-export the importing module.

The mitigation: use barrel files at intentional architectural boundaries (the public API of a feature module), not automatically for every directory. If you encounter undefined for a symbol that your types say should be an object, suspect a barrel-induced circular initialization issue and remove the barrel temporarily to diagnose.

3. Path Aliases Require Configuration in Every Tool — Not Just tsconfig.json

When you add a path alias to tsconfig.json, TypeScript’s type checker resolves imports correctly. But TypeScript’s compiler output is JavaScript — it does not transform alias paths. The runtime (Node.js, a bundler, a test runner) also needs to know about the aliases.

This means your alias configuration must be registered in every tool that processes the code:

// tsconfig.json — TypeScript type checking
{
  "compilerOptions": {
    "paths": { "@/*": ["src/*"] }
  }
}
// vitest.config.ts — test runner must also know
import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: { /* ... */ },
});
// For NestJS running with ts-node: register tsconfig-paths at startup
// In package.json scripts:
// "start:dev": "ts-node -r tsconfig-paths/register src/main.ts"

If you add an alias to tsconfig.json and immediately get “Cannot find module ‘@/…’ “ at runtime or in tests, the runtime-level alias registration is missing. The TypeScript compiler accepted it (because it reads tsconfig.json), but the tool executing the JavaScript does not know about the alias.

4. Default Exports Make Refactoring Harder Than It Looks

If you come from a React codebase using export default, you will encounter components that can be imported under any name:

// These all import the same default export:
import UserCard from './UserCard';
import Card from './UserCard';
import WhateverIWant from './UserCard';

This makes automated refactoring unreliable. If you rename the component inside the file, no import needs to update — but now the file’s name, the internal name, and the import alias are all potentially different. When searching for usages of UserCard in a large codebase, you will miss the files that imported it as Card.

Named exports enforce a single name for a symbol across the codebase, making search, refactoring, and code review more reliable.

5. The require() / import Mismatch Will Produce Runtime Errors, Not Type Errors

If you are in a CJS context (common in NestJS by default) and you require() an ESM-only package, you will get this at runtime:

Error [ERR_REQUIRE_ESM]: require() of ES Module node_modules/some-package/index.js
not supported. Instead change the require of index.js to a dynamic import() which is
available in all CommonJS modules.

TypeScript’s type checker cannot detect this — from the type system’s perspective, both formats look the same. The error only surfaces at runtime, often in production if your test suite does not exercise that code path.

The fix depends on the situation:

// Option 1: Use dynamic import() to load ESM from CJS
const { someFunction } = await import('esm-only-package');

// Option 2: Find a CJS-compatible version or an alternative package
// Check npm for packages like 'some-package-cjs'

// Option 3: Convert your NestJS project to ESM (non-trivial, breaks some NestJS internals)
// Generally not recommended unless you have a specific reason

Check a package’s package.json for "type": "module" before adding it as a dependency if you are in a CJS project.


Hands-On Exercise

This exercise builds a mini NestJS feature module using the module patterns covered in this article. You will practice named exports, barrel files, and path aliases together.

Setup: Assume you have a working NestJS project. If not, nest new module-exercise --package-manager pnpm creates one.

Task: Create a notifications feature module with the following structure:

src/
  notifications/
    dto/
      create-notification.dto.ts    — Zod schema + inferred type
      notification-response.dto.ts  — Response type
    entities/
      notification.entity.ts        — Notification class/interface
    notifications.service.ts        — Service with basic CRUD stubs
    notifications.controller.ts     — REST controller
    notifications.module.ts         — NestJS module definition
    index.ts                        — Barrel file (public API of this module)

Step 1: Create src/notifications/entities/notification.entity.ts with a named export:

export interface Notification {
    id: string;
    userId: string;
    message: string;
    read: boolean;
    createdAt: Date;
}

Step 2: Create src/notifications/dto/create-notification.dto.ts using Zod for both validation and type inference (as covered in Article 2.3):

import { z } from 'zod';

export const createNotificationSchema = z.object({
    userId: z.string().uuid(),
    message: z.string().min(1).max(500),
});

export type CreateNotificationDto = z.infer<typeof createNotificationSchema>;

Step 3: Create the service with explicit imports — no implicit namespace access:

// src/notifications/notifications.service.ts
import { Injectable } from '@nestjs/common';
import { Notification } from './entities/notification.entity';
import { CreateNotificationDto } from './dto/create-notification.dto';

@Injectable()
export class NotificationsService {
    private notifications: Notification[] = [];

    create(dto: CreateNotificationDto): Notification {
        const notification: Notification = {
            id: crypto.randomUUID(),
            userId: dto.userId,
            message: dto.message,
            read: false,
            createdAt: new Date(),
        };
        this.notifications.push(notification);
        return notification;
    }

    findByUser(userId: string): Notification[] {
        return this.notifications.filter(n => n.userId === userId);
    }
}

Step 4: Create the barrel file:

// src/notifications/index.ts
export { NotificationsModule } from './notifications.module';
export { NotificationsService } from './notifications.service';
export type { Notification } from './entities/notification.entity';
export type { CreateNotificationDto } from './dto/create-notification.dto';

Step 5: Add a path alias to tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@notifications/*": ["src/notifications/*"],
      "@notifications": ["src/notifications/index.ts"]
    }
  }
}

Step 6: From any other module in the project, verify you can import from the barrel using the alias:

// src/some-other-feature/some.service.ts
import { NotificationsService } from '@notifications';
import type { Notification } from '@notifications';

Verification: Run pnpm tsc --noEmit to confirm no type errors. Then run the app with pnpm start:dev and verify the module loads without resolution errors.

Extension: Add a second module (email-sender) that the NotificationsService depends on, and deliberately create a circular import between them. Observe the runtime behavior, then resolve the circular dependency by extracting a shared interface into a notifications.types.ts file that both modules import from.


Quick Reference

.NET ConceptTypeScript EquivalentNotes
namespace MyApp.ModelsFile is the module; no namespace declaration neededThe file path IS the module identity
using MyApp.Models;import { User, UserDto } from './models';Must name each symbol explicitly
using MyApp.Models; (all symbols)import * as Models from './models';Avoid — prevents tree-shaking
global using MyApp.Services;Barrel file + path aliasNot exactly equivalent; reduces per-file verbosity
Public class (visible outside assembly)export class FooDefault is private to file without export
Internal class (same assembly only)No direct equivalentBarrel files simulate this by not re-exporting
Assembly reference in .csprojListed in package.json dependenciesResolved from node_modules
Lazy loading (MEF / Assembly.Load)const m = await import('./my-module')Returns a Promise; resolved at call time
Project reference in .csprojpnpm workspace dependency"@myapp/shared": "workspace:*" in package.json
using alias (using Svc = MyApp.Services.UserService)import { UserService as Svc } from '...'Inline alias in the import statement
Re-export (forwarding)export { Foo } from './foo'Used in barrel files
Default export (avoid)export default class FooImport with import Foo from '...' (no braces)
Named export (prefer)export class FooImport with import { Foo } from '...'
Path aliastsconfig.json paths + runtime registrationMust configure in tsconfig AND vitest/bundler

Further Reading

  • TypeScript Module Documentation — The official handbook covers every module mode, resolution algorithm, and tsconfig option with examples. The “Modules” chapter is the authoritative reference.
  • Node.js ESM Documentation — Covers the CJS/ESM interop rules, .cjs/.mjs file extensions, and the "type": "module" package flag. Essential reading when you hit ERR_REQUIRE_ESM.
  • TypeScript Module Resolution — The tsconfig reference for moduleResolution, module, paths, and baseUrl. Use this when configuring a new project from scratch or diagnosing a resolution error.
  • Barrel Files: Good or Bad? — A balanced analysis of barrel file trade-offs, with specific guidance on when they help and when they hurt bundle size and build performance.