Skip to content
TypeScript ts best-practices 4 min read

Code Organization

How you organize code matters as much as the code itself. A well-organized TypeScript project keeps related things close, hides implementation details behind clear module boundaries, and makes the dependency direction obvious at a glance. The language gives you the tools — ES modules, import type, path aliases, and re-export barrels — but they help only when applied with discipline. This page focuses on organizing within and across modules: where types live, how to keep imports clean, when barrels help versus hurt, and how to avoid the circular dependencies that creep into growing codebases. (Directory layout is covered separately in Folder Structure.)

One module, one responsibility

Each file should have a single, nameable purpose. A module that exports a cohesive set of related functions or one main class is easy to find, test, and reason about; a 1,000-line grab-bag is not.

// ❌ utils.ts — an unfocused dumping ground
export function formatDate() {/* ... */}
export function parseJwt() {/* ... */}
export function clamp() {/* ... */}
export class HttpClient {/* ... */}

// ✅ split by responsibility
// date.ts        -> formatDate, addDays
// auth/jwt.ts    -> parseJwt, signJwt
// math.ts        -> clamp, lerp
// http/client.ts -> HttpClient

Naming a file becomes trivial when it does one thing. If you struggle to name it without “utils” or “helpers”, it is probably doing too much and should be split.

Co-locate types with their usage

Put types next to the code that owns them. Domain types that several modules share belong in a dedicated types.ts (or model.ts) for that feature; types used by a single module stay in that file. Use import type for type-only imports so the compiler can erase them cleanly.

// user/model.ts
export interface User {
  id: string;
  email: string;
}

// user/service.ts
import type { User } from "./model";
import { db } from "../db";

export async function findUser(id: string): Promise<User | null> {
  return db.users.findById(id);
}

import type signals intent and guarantees the import is removed from the JavaScript output, which prevents accidental runtime coupling and side-effect imports. With verbatimModuleSyntax enabled, the compiler enforces this distinction strictly.

Use path aliases over deep relative imports

Long ../../../ chains are brittle and obscure where a module lives. Configure paths in tsconfig.json to give stable, readable import roots.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@features/*": ["src/features/*"]
    }
  }
}
// ❌ import { User } from "../../../user/model";
import { User } from "@features/user/model"; // ✅ stable regardless of file depth

Aliases survive file moves and make the import’s origin obvious. Remember that bundlers/runtimes need matching resolution config (e.g. tsconfig-paths, or your bundler’s alias settings) since tsc alone does not rewrite paths at runtime.

Barrel files: helpful but use sparingly

A barrel (index.ts) re-exports a module’s public surface so consumers import from one path. They tidy a feature’s public API but can hurt build performance and cause circular imports if overused.

// features/user/index.ts — the feature's public API
export { findUser } from "./service";
export type { User } from "./model";
// deliberately NOT exporting internal helpers
import { findUser, type User } from "@features/user";
Barrels helpBarrels hurt
Defining a feature’s public APIRe-exporting everything indiscriminately
Hiding internal file structureImporting a barrel from inside the same barrel (cycles)
Fewer, clearer import linesPulling in unused code, slowing tree-shaking

Keep barrels at feature boundaries only, and never import a feature’s own barrel from a file inside that feature — that is the classic recipe for a circular dependency.

Control dependency direction

Dependencies should flow one way: high-level features may depend on shared low-level utilities, but utilities must never import features. Enforce this with an ESLint rule (import/no-cycle, import/no-restricted-paths) so violations fail the build rather than rotting silently.

// ✅ feature -> shared (allowed)
// ❌ shared/logger.ts importing features/user (forbidden, inverts the layers)

Keeping the arrows pointing inward toward shared, stable code prevents tangles and keeps each layer independently testable.

Best Practices

  • Give every module one clear responsibility; split anything you can only call “utils”.
  • Co-locate single-use types; put shared domain types in a feature model.ts.
  • Use import type (and verbatimModuleSyntax) for type-only imports.
  • Configure paths aliases to kill deep ../../../ import chains.
  • Limit barrels to feature public APIs; never re-export internals wholesale.
  • Keep dependencies flowing one direction and enforce it with import/no-cycle.
  • Prefer named exports over default exports for greppability and safe refactors.
Last updated June 29, 2026
Was this helpful?