Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Modules & Configuration

Understanding how TypeScript handles modules and project configuration is essential for building real-world applications. Many developers skip this chapter and then spend hours debugging mysterious “cannot find module” errors or wondering why their build output is wrong. Modules are how you organize code into separate files, and tsconfig.json is how you tell the compiler what rules to enforce and how to produce output. Getting these right from the start saves days of pain later.

1. ES Modules

TypeScript fully supports ES modules (ESM), the standard JavaScript module system. ES modules use import/export syntax and are the direction the entire JavaScript ecosystem is moving. If you have used require() in Node.js, ESM is the modern replacement with better static analysis (TypeScript can check imports at compile time), tree-shaking support (bundlers can remove unused exports), and standardized behavior across browsers and Node.js.

Named Exports

// utils.ts
export const PI = 3.14159;

export function add(a: number, b: number): number {
  return a + b;
}

export function subtract(a: number, b: number): number {
  return a - b;
}

export interface User {
  id: number;
  name: string;
}

export class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }
}
// main.ts
import { PI, add, subtract, User, Calculator } from './utils';

console.log(PI);        // 3.14159
console.log(add(2, 3)); // 5

const user: User = { id: 1, name: 'Alice' };
const calc = new Calculator();

Default Exports

// User.ts
export default class User {
  constructor(public name: string, public email: string) {}

  greet(): string {
    return `Hello, I'm ${this.name}`;
  }
}

// main.ts
import User from './User'; // No braces for default import

const user = new User('Alice', 'alice@example.com');

Mixed Exports

// api.ts
export const API_URL = 'https://api.example.com';
export const VERSION = '1.0.0';

export default class ApiClient {
  constructor(private baseUrl = API_URL) {}

  async get<T>(path: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`);
    return response.json();
  }
}

// main.ts
import ApiClient, { API_URL, VERSION } from './api';

Import Aliases

import { add as sum, subtract as minus } from './utils';

console.log(sum(2, 3));   // 5
console.log(minus(5, 2)); // 3

Namespace Import

import * as utils from './utils';

console.log(utils.PI);
console.log(utils.add(2, 3));

Type-Only Imports

// Only import types (removed at runtime -- zero bytes in the JS output)
// Use this when you only need the type for annotations, not runtime values
import type { User, Config } from './types';

// Combined import -- fetchUser is a runtime value, User is a type-only import
// This makes it explicit which imports are erased and which survive compilation
import { fetchUser, type User } from './api';

// Type-only export -- consumers can use this type, but it adds nothing to the bundle
export type { User, Config };
Practical tip: import type is not just good practice — it prevents accidental side effects. If a module has top-level code that runs on import, import type guarantees that code will not execute. Some bundlers also rely on import type for better tree-shaking.

2. Re-exports (Barrel Files)

Create a single entry point for multiple modules. Barrel files (typically named index.ts) act as a “public API” for a directory — consumers import from the barrel rather than reaching into individual files. This is one of the most common organizational patterns in TypeScript projects.
// models/User.ts
export interface User {
  id: number;
  name: string;
}

// models/Product.ts
export interface Product {
  id: number;
  name: string;
  price: number;
}

// models/index.ts (barrel file)
export { User } from './User';
export { Product } from './Product';
export * from './Order';  // Re-export everything from Order

// main.ts
import { User, Product, Order } from './models';

Selective Re-exports

// services/index.ts
export { UserService } from './UserService';
export { ProductService } from './ProductService';
// OrderService is not exported (internal use only)

// Rename on re-export
export { InternalService as ExternalService } from './InternalService';

3. Module Resolution

How TypeScript finds imported modules. Module resolution is the process of turning an import path ('./utils', 'express') into an actual file on disk. Getting this wrong produces the dreaded “Cannot find module” errors. Understanding these strategies eliminates an entire category of configuration headaches.

Relative Imports

// Relative paths - start with ./ or ../
import { utils } from './utils';         // Same directory
import { config } from '../config';      // Parent directory
import { helper } from './lib/helper';   // Subdirectory

Non-relative Imports

// Node modules
import express from 'express';
import { Request, Response } from 'express';

// Path aliases (configured in tsconfig)
import { User } from '@models/User';
import { api } from '@services/api';

Module Resolution Strategies

// tsconfig.json
{
  "compilerOptions": {
    // "node" - Node.js style resolution
    // "classic" - TypeScript's original resolution (rarely used)
    // "node16" / "nodenext" - ESM-aware Node.js resolution
    "moduleResolution": "node"
  }
}

4. Declaration Files (.d.ts)

Type definitions for JavaScript libraries. Declaration files (.d.ts) are the bridge between TypeScript and the vast JavaScript ecosystem. They describe the types of a JavaScript library without modifying its source code — like a separate “instruction manual” that tells TypeScript what shapes a library’s exports have.

Using @types Packages

npm install --save-dev @types/node
npm install --save-dev @types/express
npm install --save-dev @types/lodash

Creating Declaration Files

// types/mylib.d.ts
declare module 'mylib' {
  export function doSomething(value: string): number;
  export const VERSION: string;

  export interface Options {
    debug?: boolean;
    timeout?: number;
  }

  export default class MyLib {
    constructor(options?: Options);
    process(data: string): string;
  }
}

Ambient Declarations

// globals.d.ts
declare const __DEV__: boolean;
declare const __VERSION__: string;

declare function gtag(command: string, ...args: any[]): void;

declare interface Window {
  analytics: {
    track(event: string, data?: object): void;
  };
}

5. Namespaces

TypeScript’s original module system (still useful for type organization).
namespace Validation {
  export interface Validator {
    isValid(value: string): boolean;
  }

  export class EmailValidator implements Validator {
    isValid(value: string): boolean {
      return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
    }
  }

  export class PhoneValidator implements Validator {
    isValid(value: string): boolean {
      return /^\d{10}$/.test(value);
    }
  }
}

// Usage
const emailValidator = new Validation.EmailValidator();
emailValidator.isValid('test@example.com'); // true

Nested Namespaces

namespace App {
  export namespace Models {
    export interface User {
      id: number;
      name: string;
    }
  }

  export namespace Services {
    export class UserService {
      getUser(id: number): Models.User | null {
        return null;
      }
    }
  }
}

// Usage
const user: App.Models.User = { id: 1, name: 'Alice' };
const service = new App.Services.UserService();
Prefer ES modules over namespaces for new code. Namespaces are still useful for:
  • Organizing types in declaration files
  • Global type augmentation
  • Legacy codebases

6. tsconfig.json

The TypeScript configuration file. Run tsc --init to generate one. This is the single most important file in a TypeScript project — it controls what gets compiled, how strict the type checking is, what JavaScript version to target, and how modules are resolved. A misconfigured tsconfig.json leads to false confidence (too lenient) or developer frustration (too strict without understanding why).

Essential Options

{
  "compilerOptions": {
    // Target JavaScript version
    "target": "ES2022",

    // Module system for output
    "module": "NodeNext",

    // How to resolve imports
    "moduleResolution": "NodeNext",

    // Output directory
    "outDir": "./dist",

    // Source directory
    "rootDir": "./src",

    // Enable all strict type checks
    "strict": true,

    // Allow importing .json files
    "resolveJsonModule": true,

    // Ensure consistent casing in imports
    "forceConsistentCasingInFileNames": true,

    // Skip type checking of declaration files
    "skipLibCheck": true,

    // Generate .d.ts files
    "declaration": true,

    // Generate source maps
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Strict Mode Options

{
  "compilerOptions": {
    "strict": true,
    // Or individually:
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true
  }
}

Additional Checks

{
  "compilerOptions": {
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

Path Aliases

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@models/*": ["src/models/*"],
      "@services/*": ["src/services/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}
// With path aliases
import { User } from '@models/User';
import { fetchUser } from '@services/api';
import { formatDate } from '@utils/date';
Path aliases require additional configuration in your bundler (Webpack, Vite) or Node.js (tsconfig-paths package).

7. Project References

Split large projects into smaller, independently compiled pieces.

Main tsconfig.json

{
  "files": [],
  "references": [
    { "path": "./packages/core" },
    { "path": "./packages/api" },
    { "path": "./packages/web" }
  ]
}

Package tsconfig.json

// packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

// packages/api/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "references": [
    { "path": "../core" }
  ],
  "include": ["src/**/*"]
}

Build Command

# Build all projects
tsc --build

# Build with watch
tsc --build --watch

# Clean build
tsc --build --clean

8. Common Configurations

Node.js Backend

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

React Frontend

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "allowImportingTsExtensions": true
  },
  "include": ["src"]
}

Library

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.test.ts"]
}

9. Module Augmentation

Extend existing modules and types.

Extending Third-Party Types

// Extend Express Request
declare module 'express' {
  interface Request {
    user?: {
      id: string;
      role: 'admin' | 'user';
    };
  }
}

// Now TypeScript knows about req.user
import { Request, Response } from 'express';

function handler(req: Request, res: Response) {
  if (req.user) {
    console.log(req.user.id);
  }
}

Extending Global Types

// Extend Window
declare global {
  interface Window {
    __INITIAL_STATE__: {
      user: User | null;
      config: AppConfig;
    };
  }
}

// Extend Array
declare global {
  interface Array<T> {
    first(): T | undefined;
    last(): T | undefined;
  }
}

Array.prototype.first = function () {
  return this[0];
};

Array.prototype.last = function () {
  return this[this.length - 1];
};

10. Build Tools Integration

package.json Scripts

{
  "scripts": {
    "build": "tsc",
    "build:watch": "tsc --watch",
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src --ext .ts",
    "test": "vitest"
  }
}

ESLint Configuration

// eslint.config.js
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  {
    rules: {
      '@typescript-eslint/no-unused-vars': 'error',
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/explicit-function-return-type': 'off'
    }
  }
);

Vitest Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html']
    }
  }
});

Summary

ConceptExample
Named Exportexport { User }
Default Exportexport default class User
Named Importimport { User } from './User'
Default Importimport User from './User'
Type-only Importimport type { User } from './User'
Barrel Fileexport * from './module'
Declaration File.d.ts files
Namespacenamespace App { }
Path Aliases"@models/*": ["src/models/*"]
Project References"references": [{ "path": "./pkg" }]
Module Augmentationdeclare module 'express' { }

What’s Next?

Congratulations! You’ve completed the TypeScript Crash Course. You now have a solid foundation in:
  • ✅ Type annotations and inference
  • ✅ Functions and generics
  • ✅ Interfaces and type aliases
  • ✅ Classes and OOP patterns
  • ✅ Advanced types and utility types
  • ✅ Modules and configuration

Continue Learning

React + TypeScript

Build type-safe React applications with hooks, context, and components.

Node.js + TypeScript

Create robust backend APIs with Express, NestJS, or Fastify.

Full-Stack TypeScript

End-to-end type safety with tRPC, Prisma, and Next.js.

Advanced Patterns

Explore design patterns, dependency injection, and architecture.

Interview Deep-Dive

Strong Answer:Type-only imports (import type { User } from './types') are a TypeScript feature that tells the compiler “this import is only used for type checking — remove it entirely from the output JavaScript.”
  • Why they exist: TypeScript’s type erasure removes type annotations from the output, but the import statement itself is JavaScript — it stays in the output and executes at runtime. If you import an interface (import { User } from './types') and User is only used as a type annotation, the compiled JavaScript still has import { User } from './types', which imports a module that might have side effects, adds to your bundle size, or causes circular dependency issues — all for a value that is never used at runtime.
  • What import type does: It guarantees the import is erased completely. The compiled JavaScript has no trace of it. No module is loaded, no code executes, no bundle size impact. This is critical for tree-shaking: bundlers like Webpack and Rollup can more aggressively remove dead code when they know an import is type-only.
  • When they matter most: Circular dependencies. If module A imports a type from module B, and module B imports a value from module A, a regular import creates a circular dependency that can cause runtime errors (one module loads before the other is ready). With import type, module A’s import is erased, breaking the cycle. I have seen production applications crash on startup because of circular dependencies that only existed for type imports.
  • The isolatedModules flag: When enabled (required by tools like esbuild and Babel that compile files individually), TypeScript requires that imports used only as types are marked with import type. Without this flag, TypeScript can figure it out during full-project compilation, but single-file compilers cannot.
Follow-up: What is the ‘verbatimModuleSyntax’ flag in TypeScript 5.0, and does it replace ‘import type’?verbatimModuleSyntax is the successor to isolatedModules and preserveValueImports. When enabled, TypeScript enforces a simple rule: any import that is not explicitly import type will be emitted in the JavaScript output as-is. There is no “smart” detection of whether an import is type-only. This makes the behavior predictable across all build tools: what you write is what you get. It does not replace import type — it makes import type mandatory for type-only imports, which is arguably the correct default since it eliminates ambiguity and makes the developer’s intent explicit.
Strong Answer:Barrel files are index.ts files that re-export from multiple modules, creating a single import point: import { User, Product, Order } from './models' instead of three separate imports.
  • The appeal: Clean import paths, centralized API surface, easy refactoring (move a file and only update the barrel). They feel elegant and reduce import clutter.
  • Why some teams ban them: Performance. When you import one thing from a barrel file, the module bundler (or Node.js) must load and evaluate every module the barrel re-exports. import { User } from './models' triggers loading User.ts, Product.ts, Order.ts, and every other module in the barrel — even though you only need User. In a large codebase with hundreds of models, this can add seconds to startup time and megabytes to bundle size.
  • Tree-shaking is not a complete fix: Bundlers like Webpack and Rollup can theoretically tree-shake unused re-exports, but this only works for statically analyzable, side-effect-free modules. If any module in the barrel has top-level side effects (database connection, global event listener, polyfill), the bundler must include it. In practice, barrel files defeat tree-shaking more often than developers realize.
  • The IDE performance impact: Large barrel files slow down TypeScript’s language service. When you type import { } from './models', the IDE must resolve every export from every re-exported module to populate autocomplete. In monorepos with deep barrel chains (barrel re-exports from another barrel), this cascades and can make autocomplete take 2-3 seconds.
  • My recommendation: Use barrel files for small, cohesive modules (a UI component library with 10-15 components). Avoid them for large, heterogeneous collections (all models, all services, all utilities). Use direct imports for performance-critical paths.
Follow-up: How do path aliases interact with barrel files, and what is the common misconfiguration?Path aliases (@models/User mapping to src/models/User) are often used alongside barrel files to create clean import paths. The common misconfiguration is setting up aliases in tsconfig.json but forgetting to configure the same aliases in the bundler (Webpack’s resolve.alias, Vite’s resolve.alias, or the tsconfig-paths package for Node.js). TypeScript compiles without error because it resolves the alias during type checking, but the runtime or bundler cannot find the module because it uses a different resolution algorithm. The result: “Module not found” errors that only appear at build time or runtime, not during type checking.
Strong Answer:"strict": true is an umbrella flag that enables 8 individual strict checks. Understanding each one matters because teams sometimes need to enable them incrementally during migration.
  • noImplicitAny: Variables and parameters without type annotations default to any in non-strict mode. With this flag, TypeScript errors if it cannot infer a type, forcing you to annotate. This is the single most impactful strict flag because any defeats the entire purpose of TypeScript.
  • strictNullChecks: Without this, null and undefined are assignable to every type. const name: string = null compiles. With this flag, null must be explicitly included in the type: string | null. This catches null reference errors at compile time — the most common runtime error in JavaScript.
  • strictFunctionTypes: Enables contravariant checking for function parameter types. Without it, TypeScript allows unsound assignments where a function accepting a base type is assigned to a variable expecting a function accepting a derived type. This is technically a type safety hole that strict mode closes.
  • strictBindCallApply: Checks that Function.prototype.bind, .call, and .apply are called with correct argument types. Without it, these methods accept any arguments.
  • strictPropertyInitialization: Class properties must be initialized in the constructor or have a definite assignment assertion (!). Prevents accessing uninitialized properties.
  • noImplicitThis: Errors when this has an implicit any type (typically in standalone functions). Forces explicit this parameter annotations.
  • useUnknownInCatchVariables: Makes catch(e) variables unknown instead of any, forcing you to narrow the error type before accessing properties.
  • alwaysStrict: Emits 'use strict' in every output file.
Should every project use it? New projects: absolutely yes, from day one. The cost of adding strict mode later (fixing thousands of errors across the codebase) is dramatically higher than starting with it. Legacy JavaScript-to-TypeScript migrations: enable flags incrementally. Start with noImplicitAny (catches the most bugs), then strictNullChecks (the hardest to retrofit but most valuable), then the rest.Follow-up: What is the ‘noUncheckedIndexedAccess’ flag, and why is it not included in ‘strict’?It adds | undefined to every index signature and array index access. const first = arr[0] becomes T | undefined. It is not in strict because the TypeScript team decided it was too disruptive for existing codebases — it requires null checks on every array access, which is verbose in code that already validates array bounds. I recommend enabling it for new projects that handle external data heavily (API responses, user input), and leaving it off for projects where arrays are always pre-validated.
Strong Answer:Project References split a large TypeScript project into smaller, independently compiled units. They solve the problem of “compiling 500,000 lines of TypeScript takes 45 seconds even though I only changed one file.”
  • How they work: Each sub-project has its own tsconfig.json with "composite": true. A root tsconfig.json lists all sub-projects in "references": [{"path": "./packages/core"}, {"path": "./packages/api"}]. When you run tsc --build, TypeScript compiles each sub-project independently and caches the output (.d.ts files and .tsbuildinfo). On subsequent builds, it only recompiles sub-projects whose source files changed.
  • The composite flag requirement: "composite": true tells TypeScript “this sub-project must produce declaration files and a build info cache.” It enforces that the sub-project is self-contained: all source files must be under rootDir, and the project must emit .d.ts files so other sub-projects can import types without re-analyzing the source.
  • When to use them: Monorepos with multiple packages that depend on each other. Example: packages/core (shared types and utilities), packages/api (backend, depends on core), packages/web (frontend, depends on core). Without project references, tsc analyzes all three packages every time. With project references, changing a file in packages/web only recompiles packages/web because packages/core and packages/api are cached.
  • The performance impact: In a monorepo with 10 packages and 200K lines of TypeScript, project references can reduce incremental build times from 30+ seconds to 2-3 seconds. The initial build is similar, but subsequent builds are dramatically faster.
  • The trade-off: More configuration complexity. Each package needs its own tsconfig.json. Dependencies between packages must be declared explicitly in references. Circular dependencies between packages are a compile error (which is actually a good constraint).
Follow-up: How do Project References interact with build tools like Turborepo or Nx?Turborepo and Nx operate at the task level (run build, test, lint for each package), while Project References operate at the TypeScript compilation level. They are complementary: Turborepo decides which packages need rebuilding based on file changes and dependency graphs, and within each package, tsc --build uses Project References to skip unchanged sub-compilations. The practical setup is: Turborepo orchestrates tsc --build for each package, caching the entire output. Project References handle the TypeScript-specific incremental compilation within a single tsc invocation.