javascriptroom guide

Structuring Large TypeScript Applications: Tips and Techniques

Starting a TypeScript project is exciting—clean code, type safety, and modern tooling make development a breeze. But as your application grows—more features, more team members, more complexity—chaos can creep in. What was once a manageable codebase becomes a labyrinth of files, circular dependencies, and ambiguous type definitions. Navigating it feels like solving a puzzle, and adding new features becomes a risky endeavor. The root cause? Poor structure. Structuring a large TypeScript application isn’t just about organizing files into folders—it’s about creating a **scalable, maintainable, and predictable system** that grows with your team and requirements. It ensures consistency, reduces cognitive load, and makes onboarding new developers easier. In this blog, we’ll dive into proven strategies, patterns, and tools to structure large TypeScript apps effectively. Whether you’re building a enterprise SaaS platform, a complex frontend, or a backend service, these techniques will help you keep your codebase under control.

Table of Contents

  1. Project Architecture Patterns
  2. Folder Structure: Organizing Your Code
  3. Module Boundaries and Encapsulation
  4. Dependency Management
  5. Type Safety: Leveraging TypeScript’s Superpowers
  6. State Management for Large Apps
  7. Testing Strategies for Large Codebases
  8. Tooling: Automate and Enforce Standards
  9. Performance: Structure for Speed
  10. Conclusion
  11. References

1. Project Architecture Patterns

Before diving into folder structures, you need to choose a high-level architecture that aligns with your app’s scale and team structure. Here are the most common patterns for large TypeScript apps:

1.1 Monorepos

A monorepo (monolithic repository) stores all your code—frontend, backend, shared libraries, tools—in a single repository. Tools like Nx, Turborepo, or Lerna help manage monorepos by handling dependency sharing, task execution, and code generation.

Benefits:

  • Shared code (e.g., TypeScript types, utilities) is reused across projects without duplication.
  • Atomic commits: Update frontend and backend in a single PR.
  • Consistent tooling and standards across the codebase.

Use Case: Large teams building multiple interconnected apps (e.g., a web app, mobile app, and API server) that share business logic or types.

1.2 Modular Monoliths

A modular monolith is a single application divided into loosely coupled modules (e.g., “auth”, “payments”, “dashboard”) with clear boundaries. Unlike microservices, modules live in the same codebase but are isolated to prevent unintended dependencies.

Benefits:

  • Simpler deployment than microservices (single deployable unit).
  • Easier debugging (no cross-service network calls).
  • Clear module boundaries enforce separation of concerns.

Use Case: Mid-to-large apps where microservices would add unnecessary complexity (e.g., internal tools, SaaS platforms with tightly linked features).

1.3 When to Choose Which?

  • Monorepo: Use if you have multiple apps (e.g., web + mobile) or shared libraries.
  • Modular Monolith: Use for a single large app where modules are tightly related but need isolation.
  • Microservices: Only use if modules are completely independent (e.g., separate products under a company umbrella) and you can tolerate distributed system complexity.

2. Folder Structure: Organizing Your Code

Once you’ve chosen an architecture, the next step is organizing files and folders. A poor folder structure forces developers to hunt for code; a good one makes it obvious where everything lives.

2.1 Feature-Based vs. Layer-Based Structure

Two popular approaches:

  • Layer-Based: Organize by technical role (e.g., components/, hooks/, services/).
    Problem: For large apps, related code (e.g., “auth” components, hooks, and services) gets scattered across folders, making it hard to reason about a feature as a whole.

  • Feature-Based: Organize by business feature (e.g., features/auth/, features/dashboard/), with subfolders for technical layers within each feature.
    Why it works: All code for a feature lives in one place, making it easy to navigate, test, and refactor.

2.2 A Sample Feature-Based Folder Tree

Here’s a feature-based structure for a modular monolith (extendable to monorepos):

src/
├── features/               # Core business features
│   ├── auth/               # Auth feature module
│   │   ├── components/     # Feature-specific UI components
│   │   │   ├── login-form.tsx
│   │   │   └── auth-modal.tsx
│   │   ├── hooks/          # Feature-specific hooks
│   │   │   └── use-auth.ts
│   │   ├── services/       # Feature-specific API/services
│   │   │   └── auth-api.ts
│   │   ├── types/          # Feature-specific types
│   │   │   └── auth.types.ts
│   │   ├── utils/          # Feature-specific utilities
│   │   │   └── token-utils.ts
│   │   ├── auth.slice.ts   # State management (e.g., Redux/Zustand)
│   │   └── index.ts        # Barrel file (public API)
│   ├── dashboard/          # Another feature module
│   │   └── ...             # Same subfolder structure as auth
│   └── payments/           # And another...
├── shared/                 # Code reused across features
│   ├── components/         # Generic UI components (e.g., Button, Card)
│   ├── hooks/              # Shared hooks (e.g., useLocalStorage)
│   ├── services/           # Shared services (e.g., API client setup)
│   ├── types/              # Shared types (e.g., API response shapes)
│   └── utils/              # Shared utilities (e.g., date formatting)
├── app/                    # App-wide setup
│   ├── App.tsx             # Root component
│   ├── routes.tsx          # Routing configuration
│   └── store.ts            # Global state setup
└── index.tsx               # Entry point

Key Principle: Colocate related code. If a file belongs to a feature, keep it in that feature’s folder. Only move code to shared/ if it’s truly reusable across multiple features.

2.3 Shared Code: The shared/ Directory

The shared/ folder is a double-edged sword: too much shared code creates hidden dependencies; too little leads to duplication. Use it for:

  • Generic UI components (e.g., Button, Modal) that aren’t tied to a specific feature.
  • Cross-cutting concerns (e.g., logging, API clients, error handling).
  • Shared types (e.g., ApiResponse<T>, PaginationParams).

Rule of Thumb: If you need to import a shared/ utility into a feature, ask: “Is this utility only used by this feature?” If yes, move it to the feature’s utils/ folder.

3. Module Boundaries and Encapsulation

In large apps, modules (features) must be encapsulated: they should expose a clear public API and hide internal implementation details. Without boundaries, changes in one feature can accidentally break another, and dependencies become a tangled web.

3.1 Barrel Files: Controlling Public APIs

A barrel file (index.ts) in each feature folder explicitly defines what’s public. Instead of importing directly from deep file paths, other modules import from the barrel.

Example features/auth/index.ts:

// Expose public API
export * from './components/login-form';
export * from './hooks/use-auth';
export * from './types/auth.types';
export { authSlice } from './auth.slice';

// Hide internal files (not exported here)
// e.g., token-utils.ts, auth-api.ts are only used within the auth feature

Now, other features import from @features/auth instead of @features/auth/hooks/use-auth:

// Good: Import from the feature's public API
import { useAuth, LoginForm } from '@features/auth';

// Bad: Import from internal file (breaks encapsulation)
import { useAuth } from '@features/auth/hooks/use-auth';

This ensures internal changes (e.g., moving use-auth.ts to a new folder) don’t break other modules.

3.2 Enforcing Boundaries with Tooling

Even with barrel files, developers may accidentally import internal files. Use tools like:

Example ESLint rule to block internal imports:

// .eslintrc.json
{
  "rules": {
    "import/no-internal-modules": [
      "error",
      {
        "allow": ["@features/*", "@shared/*"] // Only allow imports from barrel files
      }
    ]
  }
}

4. Dependency Management

As your app grows, managing dependencies between files and features becomes critical. Uncontrolled dependencies lead to circular imports, tight coupling, and fragile code.

4.1 Path Aliases: Simplify Imports

Long relative paths like ../../../../features/auth are error-prone and hard to read. TypeScript supports path aliases to replace them with clean, absolute paths.

Configure path aliases in tsconfig.json:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".", // Resolve paths relative to the project root
    "paths": {
      "@features/*": ["src/features/*"], // Map @features/auth to src/features/auth
      "@shared/*": ["src/shared/*"],     // Map @shared/components to src/shared/components
      "@app/*": ["src/app/*"]            // Map @app/store to src/app/store.ts
    }
  }
}

Now you can write:

// Clean and readable!
import { useAuth } from '@features/auth';
import { Button } from '@shared/components';

Note for Bundlers: If using Webpack, Vite, or Rollup, you’ll need to mirror these aliases in your bundler config (e.g., vite.config.ts for Vite).

4.2 Avoiding Circular Dependencies

Circular dependencies (e.g., Feature A imports Feature B, which imports Feature A) are a silent killer of maintainability. They make code harder to test, refactor, and debug.

How to Detect Them:
Use tools like madge to visualize dependencies:

npx madge --circular --extensions ts src/

How to Fix Them:

  1. Extract Shared Code: Move shared types or utilities to shared/ (e.g., if auth and dashboard both need a User type, define it in shared/types/user.types.ts).
  2. Use Event-Driven Patterns: Replace direct imports with event emitters (e.g., React Context, RxJS) to decouple modules.
  3. Invert Dependencies: If Feature A depends on Feature B, refactor so Feature B depends on an interface defined in A instead.

5. Type Safety: Leveraging TypeScript’s Superpowers

TypeScript’s true value shines in large apps—if you use it correctly. Loose types (any, unknown, as any) defeat the purpose and lead to bugs.

5.1 Strict Mode: Non-Negotiable

Enable TypeScript’s strict mode in tsconfig.json. It enables critical checks like:

  • strictNullChecks: Ensures null/undefined are handled explicitly.
  • noImplicitAny: Flags untyped variables/parameters.
  • strictFunctionTypes: Enforces stricter function type checking.
// tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

Pain Now, Gain Later: Strict mode will slow you down initially, but it prevents hours of debugging in large apps.

5.2 Domain-Driven Type Definitions

Define types that mirror your business domain, not just API responses. For example, instead of reusing raw API types everywhere, create domain-specific types:

// shared/types/api.types.ts (Raw API response)
export interface ApiUser {
  user_id: string;
  user_name: string;
  created_at: string; // ISO string
}

// features/auth/types/auth.types.ts (Domain type)
import { ApiUser } from '@shared/types/api.types';

export interface User {
  id: string;
  name: string;
  createdAt: Date; // Parsed Date object (not raw string)
}

// Convert API response to domain type
export const mapApiUserToUser = (apiUser: ApiUser): User => ({
  id: apiUser.user_id,
  name: apiUser.user_name,
  createdAt: new Date(apiUser.created_at),
});

This insulates your app from API changes (e.g., if the API renames user_id to id, only the mapper needs updating).

5.3 Utility Types for Reusability

TypeScript’s utility types (e.g., Partial, Pick, Omit) reduce boilerplate and keep types DRY.

Example: Define a base User type, then create variants for updates or responses:

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

// For update forms (all fields optional)
type UserUpdateInput = Partial<User>;

// For public profiles (omit sensitive fields)
type PublicUser = Omit<User, 'email' | 'createdAt'>;

// For API responses (pick only needed fields)
type UserSummary = Pick<User, 'id' | 'name'>;

6. State Management for Large Apps

State management in large apps is tricky. Too much global state leads to performance issues; too little leads to prop drilling.

6.1 Global vs. Local State: Draw the Line

Global State: Use for data shared across many features (e.g., user auth, app theme, global filters).
Local State: Use for component/feature-specific data (e.g., form inputs, modal visibility, feature-specific UI state).

Example Decision Tree:

  • Is this data used by >2 unrelated features? → Global.
  • Is it only used within a single feature? → Local (e.g., feature-specific Zustand store).
  • Is it only used in a single component? → Component state (React useState).

6.2 State Library Tradeoffs

LibraryUse CaseProsCons
ZustandSmall-to-large apps, minimal boilerplateLightweight, no context wrappingLess structured for very large teams
Redux ToolkitLarge apps, complex state logicMature, dev tools, strict patternsVerbose (even with Toolkit)
Jotai/ZustandFine-grained state, React-centricAtomic updates, minimal re-rendersSteeper learning curve
React Context + useReducerSmall apps, no external depsBuilt-in, simplePoor performance with frequent updates

Recommendation: For large apps, start with Redux Toolkit (if you need strict patterns) or Zustand (if you prefer minimalism). Avoid React Context for frequently updated global state—it causes unnecessary re-renders.

7. Testing Strategies for Large Codebases

Testing a large app feels overwhelming, but a good structure makes it manageable.

7.1 Co-Locating Tests with Code

Instead of a separate __tests__ folder at the root, place tests next to the code they test. This makes tests easy to find and ensures they’re updated when the code changes.

features/auth/
├── components/
│   ├── login-form.tsx
│   └── login-form.test.tsx  # Test lives next to the component
├── hooks/
│   ├── use-auth.ts
│   └── use-auth.test.ts     # Test lives next to the hook
└── ...

Tooling Tip: Use Jest’s testMatch to auto-detect .test.ts(x) files.

7.2 Integration Testing Across Features

Unit tests validate individual functions/components, but integration tests catch issues between features (e.g., “Does the auth flow correctly initialize the dashboard?”).

Place integration tests in a top-level tests/ folder or within a features/integration/ subfolder:

src/
├── features/
│   ├── integration/  # Tests that span multiple features
│   │   └── auth-dashboard-flow.test.ts
└── ...

8. Tooling: Automate and Enforce Standards

Manual enforcement of structure and style is impossible in large teams. Use tooling to automate checks and keep code consistent.

8.1 ESLint + TypeScript: Catch Issues Early

ESLint with @typescript-eslint catches TypeScript-specific issues (e.g., unused types, unsafe any usage).

Install dependencies:

npm install eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev

Configure .eslintrc.json:

{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/strict-type-checked"
  ],
  "rules": {
    "@typescript-eslint/no-explicit-any": "error", // Ban `any`
    "@typescript-eslint/explicit-module-boundary-types": "error", // Require return types for exports
    "import/order": ["error", { "newlines-between": "always", "groups": [["builtin", "external"], "internal", ["parent", "sibling"]] }] // Enforce import order
  }
}

8.2 Prettier: Consistent Formatting

Prettier auto-formats code to avoid bikeshedding (e.g., tabs vs. spaces, line length). Combine it with ESLint using eslint-config-prettier to disable conflicting rules.

Install:

npm install prettier eslint-config-prettier --save-dev

Update .eslintrc.json:

{
  "extends": [
    // ... other extends
    "prettier" // Disables ESLint rules that conflict with Prettier
  ]
}

8.3 Husky: Guardrails for Commits

Use Husky to run linting, formatting, and tests before commits/pushes, preventing bad code from entering the repo.

Setup:

npx husky install
npx husky add .husky/pre-commit "npx lint-staged"
npx husky add .husky/pre-push "npm test"

Add lint-staged to run checks only on staged files (faster!):

// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"]
  }
}

9. Performance: Structure for Speed

A well-structured app isn’t just maintainable—it’s also faster. Poor structure leads to bloated bundles and slow load times.

9.1 Code Splitting and Lazy Loading

Split your app into smaller bundles that load on demand. TypeScript’s module system and modern bundlers (Webpack, Vite) make this easy.

Example with React and React.lazy:

// routes.tsx
import { lazy, Suspense } from 'react';
import LoadingSpinner from '@shared/components/loading-spinner';

// Lazy-load the dashboard feature (only loads when the route is visited)
const Dashboard = lazy(() => import('@features/dashboard'));

export const routes = [
  {
    path: '/dashboard',
    element: (
      <Suspense fallback={<LoadingSpinner />}>
        <Dashboard />
      </Suspense>
    ),
  },
];

Pro Tip: Use dynamic imports for non-React code (e.g., large utility libraries) to split them into separate chunks.

9.2 Tree Shaking: Trim the Fat

Tree shaking removes unused code from your bundle. For it to work:

  • Use ES modules (import/export, not require).
  • Avoid side effects in shared modules (e.g., shared/utils should export pure functions, not run code on import).
  • Mark unused code with /*#__PURE__*/ comments if needed.

10. Conclusion

Structuring a large TypeScript application is a balancing act—between flexibility and rigidity, between feature isolation and shared code, between speed and maintainability. The key principles are:

  • Organize by feature, not layers.
  • Enforce boundaries with barrel files and tooling.
  • Leverage TypeScript’s strict mode for type safety.
  • Automate with tooling (ESLint, Prettier, Husky) to keep standards consistent.
  • Separate global and local state to avoid performance bottlenecks.

Remember: There’s no one-size-fits-all solution. Adapt these patterns to your team’s size, app’s complexity, and business needs. The goal isn’t perfection—it’s a codebase that grows with your team, not against it.

11. References