Skip to content
TypeScript ts best-practices 4 min read

Folder Structure

There is no single correct folder structure, but the good ones share traits: they group code by feature rather than by technical type, keep a clear boundary between source and build output, and make the location of any file predictable. As a project grows, structure is what keeps it navigable — a newcomer should be able to guess where something lives. This page presents a pragmatic feature-based layout for a typical Node/TypeScript service, explains the role of each directory, and shows how TypeScript project references scale the same ideas to multi-package monorepos with fast, incremental builds.

Feature-based layout

Organize the top level around what the application does, not around technical categories like controllers/ or services/. Each feature folder owns its routes, logic, model, and tests, exposing a small public surface through an index.ts.

my-app/
├── src/
   ├── features/
   ├── user/
   ├── user.controller.ts
   ├── user.service.ts
   ├── user.model.ts
   ├── user.test.ts
   └── index.ts          # feature's public API
   └── order/
       ├── order.service.ts
       ├── order.model.ts
       └── index.ts
   ├── shared/                   # cross-feature, stable utilities
   ├── http/
   ├── logger.ts
   └── types.ts
   ├── config/                   # env parsing, constants
   └── env.ts
   └── main.ts                   # composition root / entry point
├── tests/                        # e2e / integration tests
├── dist/                         # compiled output (gitignored)
├── package.json
├── tsconfig.json
└── tsconfig.build.json

Everything for a feature sits in one folder, so changing “user” rarely means touching unrelated directories. shared/ holds code many features reuse, and main.ts is the single composition root where the object graph is wired together.

Why feature-based over type-based

A type-based layout (controllers/, services/, models/ at the top) forces you to open three directories to understand one feature and grows worse with scale. Feature-based keeps related changes local.

ConcernFeature-basedType-based
Locating all code for a featureOne folderScattered across many
Deleting a featureRemove one folderHunt across layers
Merge conflictsLocalizedSpread across shared dirs
Onboarding clarityHighDrops as app grows

The type-based style is fine for very small apps, but feature-based pays off the moment you have more than a handful of domains.

Separate source from output

Keep src/ (input) and dist/ (output) strictly separate via rootDir and outDir, and never commit the build. This avoids stale artifacts and keeps the repo clean.

{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

A dedicated tsconfig.build.json can extend the base config to exclude tests from the published output, while the base tsconfig.json includes everything for editor and test tooling.

Scaling up with project references

For monorepos or large apps split into packages, TypeScript project references let each package build independently and incrementally. A root config references the leaf projects; each leaf marks itself composite.

monorepo/
├── packages/
   ├── core/
   ├── src/
   └── tsconfig.json     # "composite": true
   ├── api/
   ├── src/
   └── tsconfig.json     # references ../core
   └── web/
       └── tsconfig.json
└── tsconfig.json            # references all packages
// monorepo/tsconfig.json
{
  "files": [],
  "references": [
    { "path": "packages/core" },
    { "path": "packages/api" },
    { "path": "packages/web" }
  ]
}
// packages/api/tsconfig.json
{
  "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" },
  "references": [{ "path": "../core" }]
}

Build the whole graph in dependency order with tsc --build (or tsc -b --watch). References give you enforced boundaries between packages, faster rebuilds via .tsbuildinfo caching, and editor go-to-definition that jumps to source across packages.

Run tsc --build, not plain tsc, once you use project references — only build mode understands the reference graph and incremental caching. Plain tsc ignores references.

Best Practices

  • Organize by feature, not by technical layer, once you have more than a few domains.
  • Give each feature an index.ts that exposes only its intended public API.
  • Keep cross-cutting, stable code in shared/ and never let it import features.
  • Separate src/ and dist/ with rootDir/outDir; gitignore the build output.
  • Designate one entry/composition root (main.ts) where dependencies are wired.
  • Use project references with composite: true for monorepos and large builds.
  • Build reference graphs with tsc --build to get incremental, ordered compilation.
Last updated June 29, 2026
Was this helpful?