Practical patterns for procedural TypeScript and JavaScript, aimed at code that is easier to read, navigate, and maintain.
This guide mixes language fundamentals with conventions from my own projects. Some are widely adopted; others are personal preferences. Where I diverge from common practice, I note the trade-offs.
- π Terminology
- π‘ Core language features
- π File types and categories
- ποΈ File organization
- π·οΈ Naming conventions
- π¬ Comments
- π₯ Imports
- π€ Organizing shared code
- π§ Philosophy
Β· Β· Β·
Some of these are standard JavaScript terms; others are specific to this guide.
| Term | Meaning |
|---|---|
| Package | A JS/TS project with a package.json. |
| Application | A package meant to be run. |
| Library | A package meant to be used by other packages. |
| Term | Meaning |
|---|---|
| Root | The top-level folder of a package. |
| Branch directory | A folder below the root with a broad purpose and several nested folders. |
| Leaf directory | A folder with no subfolders. |
| Focused directory | A folder dedicated to one feature or responsibility. Often, but not always, a leaf. |
package-name/ <- Root
βββ src/ <- Branch directory
βββ components/ <- Branch directory
βββ Login/ <- Focused directory
βββ _local/ <- Leaf directory
- Compile time: Before execution, when code is type-checked and/or transformed into JavaScript.
- Runtime: When the JavaScript executes.
- Startup time: Application initialization.
- Request time: Responding to input, such as an API request or user action.
| Term | Meaning |
|---|---|
| Fixed | Keys stay the same; values may change. |
| Dynamic | Keys can be added or removed; values may change. |
| Readonly | Properties cannot be reassigned through the declaring type. |
Note: readonly is shallow:
const user: { readonly address: { city: string } } = {
address: { city: 'Paris' },
};
user.address.city = 'Rome'; // Allowed.- Class: A template for objects with shared behavior and, often, internal state.
- Object literal: The
{ ... }syntax for creating an object directly.
A plain object has Object.prototype as its direct prototype, or no prototype (Object.create(null)):
const first = {};
const second = new Object();
const third = Object.create(null);{} inherits methods like hasOwnProperty; null-prototype objects do not.
A dictionary is a plain object used as a collection of string-keyed values:
type Dict = Record<string, unknown>;Symbol keys are allowed but ignored by Object.keys() and similar methods; numeric keys become strings. Dictionary and plain object are often used interchangeably.
A plain data object holds only primitives and non-function objects. They should never modify data elsewhere.
A namespace object groups related values or functions under one name; its public properties are intended to stay readonly.
- Constant object: A namespace object of fixed values.
- Lookup table: A constant object pairing values with labels, often for UI display.
- Configured constant object: A constant object returned by a function, such as an enum-replacement helper.
- Module-object: A namespace object representing a module's public API. See File types and categories.
| Term | Meaning |
|---|---|
| Top-level function | Defined directly in a file, not inside another function, object, or class. |
| Function declaration | function name(...) { ... }. Abbreviated FD. |
| Arrow function | () => { ... }. |
| Function expression | A function created where an expression is expected (assignment, callback, return value). Arrow functions are always expressions. |
| Object method | A function defined as a method on an object, including object literals. |
| Factory function | A function whose main job is to create and return an object or function. Abbreviated FF. |
| Value factory function | A FF that returns a plain value or plain data object, with no attached behavior/methods. Abbreviated VFF. |
| Configured function | A function returned by a FF after supplying configuration, e.g. const parseUser = parseObject(UserSchema). |
| Validator function | Checks an unknown value and narrows it with a type predicate. |
Object-literal method:
const UserErrors = {
getError(name: string): string {
return `The user name is ${name}`;
},
};Value factory function (new object and date each call):
const UserDefaults = () => ({
name: '',
createdAt: new Date(),
});- Static method: Called on the class.
- Instance method: Called on an instance.
- Factory method: A static method that creates and returns an instance.
Factory-method names I use:
| Method | Purpose | Example |
|---|---|---|
of |
Build from individual values. | User.of('name', 'email') |
from |
Convert another representation. | User.from(serializedUser) |
create |
Build from defaults, a partial, or an existing object. | User.create({ name, email }) or User.create() |
| Term | Meaning |
|---|---|
| Type alias | type TypeName = .... |
| Object type literal | The { key: Type } syntax, named via an alias or used inline. |
| Interface | interface SomeInterface { ... }. |
| Utility type | A type that builds or transforms another type, usually with generics. |
Inline object type literal:
/**
* Process an item identified by its ID.
*/
function foo(arg: { id: number }) {
// ...
}Β· Β· Β·
Four building blocks: primitives, functions, objects, and types.
Seven primitive types: null, undefined, boolean, number, string, symbol, bigint.
- Autoboxing: Primitives (except
null/undefined) can use methods from their object counterparts, e.g.String.prototype. - Type coercion: Conversion between types, e.g.
'5' * 2evaluates to10.
These are different behaviors. Symbols are especially useful for unique object keys in shared code and libraries.
Prefer function declarations at the file level. Hoisting lets you put the main logic first and helpers below. Use arrow functions for callbacks and short inline logic.
/**
* Trim names and remove empty entries.
*/
function normalizeNames(names: string[]): string[] {
return names
.map(name => name.trim())
.filter(name => name.length > 0);
}Hoisting makes the function available earlier; it does not guarantee the values it uses are initialized.
Stack traces are not a reason to avoid function expressionsβJavaScript infers names for functions assigned to variables:
const normalizeName = (name: string) => name.trim();Objects commonly appear as object literals, class instances, or the runtime output of regular enums. For custom constructors, prefer class syntax over function plus new.
Readonly object literals work well as namespaces. If you only need to group values or stateless functions, you do not need a class.
const Errors = {
NameMissing: 'A name is required.',
InvalidEmail(value: string): string {
return `"${value}" is not a valid email address.`;
},
} as const;Both classes and factory functions support OO designs: factories keep private state in closures; classes use #private fields. TypeScript's private keyword is compile-time onlyβthe property is ordinary at runtime.
An object literal has no instance-private storage. State kept in non-exported module variables belongs to the module and is shared by all importers.
- The object has internal state its methods update over time.
- You need to extend a class you do not control, such as
Error.
Avoid a class:
- As a namespace. Use an object literal.
- To assemble a configured object with no lifecycle or need for
this. Use a factory function. - To wrap I/O data If we try to wrap IO-data with classes, this could cause unexpected behavior when data is serialized.
Classes make two things convenient:
- Shared methods: Instance methods live on the prototype. A factory defining methods per returned object creates new function objects each call.
- Inheritance:
class ... extends ...is simpler than wiring up a prototype chain.
Prefer factory methods for control over construction. My usual approach is a protected constructor with public create, of, and from methods. Factories provide named creation paths, can return subtypes, and handle setup that does not fit a constructor. A private constructor also works but blocks subclassing since subclasses cannot call super(). This is a house conventionβpublic constructors and new are standard.
Keep logic that does not need this outside the class, in top-level function declarations below it. See Keep classes clean.
See OO with classes vs. factory functions.
Prefer a constant object paired with a same-named type over a TypeScript enum.
Enums need more than type removal:
- Regular enums emit runtime JavaScript.
- Type-stripping tools such as Node's type-stripping mode don't support them.
erasableSyntaxOnlyrejects enum syntax.
const UserRoles = {
BASIC: 0,
ADMIN: 1,
OWNER: 2,
} as const;
type UserRoles = typeof UserRoles[keyof typeof UserRoles]; // 0 | 1 | 2
const basic: UserRoles = UserRoles.BASIC;The object and type share a name because values and types live in separate declaration spaces. This is not declaration merging.
Type aliases and interfaces both describe object shapes. The handbook's starting point: use interfaces until you need a type alias feature.
- Interfaces for object contracts you expect to extend.
- Type aliases for unions, tuples, mapped types, and other type expressions.
Interfaces can be easier for the compiler than large intersections, though it depends on the types involved.
Β· Β· Β·
File, script, and module are often used interchangeably, but scripts and modules have different scoping rules.
| Type | Meaning |
|---|---|
| Module | Usually has imports or exports. Top-level declarations are module-scoped; exports form its public API. |
| Script | A non-module. Top-level declarations can contribute to the global scope. |
Module detection also depends on moduleDetection and the package environment.
| Category | Purpose |
|---|---|
| Single-export | Exports one main item: a large function, class, or config object. |
| Module object | Default-exports a namespace object grouping the file's public values and functions. |
| Inventory | Exports several independent declarations, such as shared types or small utilities. |
| Linear | Runs a sequence of setup steps, often at startup. |
These are labels, not TypeScript file types. See File category examples.
For application logic whose structure is fixed after startup, I prefer module-object files (client and server; UI components follow their framework). Module objects give related functions one home:
- Callers import one object, not a list of functions.
- The public API is collected in one place.
- Helpers are less likely to be exported by accident.
- Related names are grouped under a namespace.
No class is needed for this.
Many style guides prefer named exports, which work better for tree-shaking. If that matters, keep the same call style with a namespace import:
import * as User from './User';
User.create();Module objects are a house convention.
Β· Β· Β·
A consistent layout makes it easier mentally break apart complex logic and keep more content within one file.
Largest to smallest: folders -> files -> regions -> sections -> blocks (at file level or inside a function).
After imports:
| Order | Region | Contents |
|---|---|---|
| 1 | Docs | File-level documentation. |
| 2 | Constants | Primitive constants, object constants, then value factory functions. |
| 3 | Types | Interfaces, aliases, other local types. |
| 4 | Classes | Small local classes. Larger ones get their own file. |
| 5 | Exec | For code the executes when the file loads, also for top level variables mutated by the functions below them. |
| 6 | Components | JSX components, when applicable. |
| 7 | Functions | Function declarations and supporting logic. |
| 8 | Export | The file's public API. |
Single-export, module-object, and linear files collect exports at the bottom. Inventory files export where declared so it is clear which items are public.
Hoisting makes this convenient, but values must still be initialized before use.
Three-line divider:
// ========================================================================= //
// CONSTANTS //
// ========================================================================= //Single-line divider within a region:
// ============================ Setup Middleware =========================== //
// Put any explanation for the section directly below its divider.
const app = express();
app.use(middleware1);
app.use(middleware2);
// Additional middleware setup goes here.
// ========================== Configure Front-end ========================== //
const FRONT_END_DIRECTORY_PATH = __dirname + '/client/html';
app.set('views', FRONT_END_DIRECTORY_PATH + '/views');
app.use(express.static(FRONT_END_DIRECTORY_PATH + '/static'));
// Additional front-end setup goes here.Short comment dividers, at file level or inside larger functions:
// apiRouter.ts β a linear file.
// ============================ Add User Routes ============================ //
const loginRouter = express.Router();
// --- Local Login ---
// Log in with a username and password.
const localRouter = express.Router();
localRouter.use('/local', addUser);
localRouter.use('/reset-password-request', sendLink);
loginRouter.use('/login', localRouter);
// --- Google Login ---
// Google login setup goes here.
/**
* Demonstrate block separators inside a larger function.
*/
function someLargeFunction() {
// --- Prepare Data ---
// Preparation steps go here.
// --- Process Results ---
// Processing steps go here.
}If you think adding comment dividers is tedious (which it is) then take a look at code-divider.
Readonly values, namespace-objects, and VFFs.
// Return a new object and current "createdAt" date on each call.
const UserDefaults = (): IUser => ({
id: 0,
name: '',
createdAt: new Date(),
});
// Build a configured constant object.
const Roles = SomeEnumLibrary({
Basic: { value: 1, label: 'Basic' },
Admin: { value: 2, label: 'Administrator' },
});VFFs break the usual function conventions because they provide values:
- Place them in Constants.
- Use PascalCase.
- Use a function expression, not a declaration.
- A noun name is fine; no verb needed.
Prefer descriptive type names. If a signature feels long, simplify the function name before replacing the type with an acronym readers must memorize:
/**
* Fetch subscriptions suspended because of failed payments.
*/
async function fetchPaymentSuspendedSubscriptions(): Promise<
SuspendedForFailedPaymentSubscription[]
> {
return database('subscriptions')
.where({ /* Query conditions. */ })
.returning('*');
}
// --- OR ---
async function fetchPaymentSuspendedSubscriptions(): Promise<SFFPS> {
return database('subscriptions')
.where({ /* Query conditions. */ })
.returning('*');
}
type SFFPS = SuspendedForFailedPaymentSubscription;A short local alias helps when a complex type repeats. Keep it near its uses and self-explanatory. The goal is readability, not fewer characters.
Large setup files need not follow region order rigidly. Group related steps into blocks and put each block's constants at its top.
For ordinary functions, use short comments to separate steps rather than blank lines:
/**
* Demonstrate a compact function layout.
*/
function normalFunction() {
// Prepare the input.
foo();
bar();
// Process the result.
blah();
whatever();
}For large functions that cannot be split, use block dividers and blank lines:
try {
// --- Prepare Input ---
foo();
bar();
// Additional preparation steps.
// --- Process Results ---
blah();
whatever();
// Additional processing steps.
} catch (err) {
handleErrorObject(err);
}Β· Β· Β·
Names should reveal purpose without opening the file.
| Item | Convention |
|---|---|
| Folder | kebab-case, or the name of its main item. |
| Linear file | kebab-case. |
| Single-export file | Match the exported item. |
| Module-object file | Match the module object's name in code. |
| Inventory file | kebab-case. |
Reserved names:
index.ts: Barrel fileβa folder or library entry point.main.ts: Application entry point.
Add a suffix when a file's purpose is still unclear:
import User from '@src/models/User.model';UPPER_SNAKE_CASE for module-level primitive constants and readonly arrays.
For namespace-style constant objects: PascalCase for the object and nested objects; UPPER_SNAKE_CASE for keys holding fixed values. If the whole object is passed around as a value rather than used as a namespace, use UPPER_SNAKE_CASE for its name and whatever property names consumers require.
PascalCase by default:
import DateUtils from '@src/utils/DateUtils';camelCase for widely used infrastructure objects with substantial initialization:
import db from '@src/infra/db';Match the filename to the object name.
PascalCase for module objects, constant objects, and VFFs is a house conventionβmany projects reserve it for classes, types, and components. I use it to distinguish value providers and namespaces from ordinary variables and functions.
camelCase. Types stay PascalCase.
camelCase for ordinary functions. PascalCase for JSX components and VFFs.
| Pattern | Use for | Example |
|---|---|---|
get... |
Retrieving or computing without I/O. | getDateAsString() |
fetch... |
Reading through I/O. | fetchUserRecords() |
is... |
Validators and type guards. | isValidUser(value: unknown): value is IUser |
...OrThrow |
Throwing counterpart to a function that may return nothing. | findUserByIdOrThrow() |
Trailing _ |
Avoiding a reserved word. | delete_() |
findUserById(id: number): IUser | null
findUserByIdOrThrow(id: number): IUserVFFs are exempt from verb naming.
PascalCase for classes, interfaces, and aliases. I/T prefixes are less common now. I use I only to distinguish an interface from a related class or object:
IUser: The database entity type.User: The module object fromUser.model.ts.
Prefix with is:
const isEnabled = true;
const isValid = false;Prefer clear names. Abbreviations are fine when readers already know them: established acronyms (URL, API), familiar shortenings (Pwd, Img), and team-understood project terms. All caps for acronyms is fine:
insertIntoURL();Avoid shortening words in UPPER_SNAKE_CASE names without good reason.
| Suffix | Meaning |
|---|---|
View |
Data shaped for UI display, e.g. UserView. |
DTO |
Data transfer object moving data between parts of an app or across boundaries. Not necessarily an API request. |
Ctx |
Objects which exist only in memory and are useful for communication between functions when using individual params would be impractical |
Label |
A display-formatted string, e.g. createdAtLabel. For properties or values, not whole objects. |
Payload |
An object shaped for an API call. |
See User.model.ts.
Β· Β· Β·
Explain purpose, intent, and what readers cannot infer from the code.
/** ... */above every function declaration.//or nothing for function expressions./** ... */for utility types, especially non-obvious ones.//for inline explanations.- Capitalize and punctuate.
- Separate major regions clearly.
Β· Β· Β·
Group by origin: third-party, application modules, nearby local files. Split long lists across lines. Prettier handles wrapping; use an import-sorting plugin or ESLint rule to enforce order.
Β· Β· Β·
Without a clear home, helper/shared logic quickly becomes a disorganized mess.
This builds on branch directory and focused directory from Terminology. Examples use React-style folders, but apply to client and server alike.
| Category | Contents |
|---|---|
utils |
Application agnostic logic. |
constants |
Readonly values and VFFs. |
types |
Standalone aliases and interfaces, no runtime code. |
ui |
Shared .jsx/.tsx files. |
Utilities may depend on third-party libraries and lower-level utilities but should generally be application agnostic.
Keep types beside closely related runtime logic; use shared types only for types that stand alone.
Create _common/ when code is shared across a branch directory. Avoid misc/ or helpers/ they become catch-alls. Category subfolders are fine, but filenames should still describe contents:
src/_common/types/utility-types.ts
Nested branches can have their own _common/:
public/
src/
βββ assets/
βββ _common/
β βββ types/
β βββ utility-types.ts
βββ components/
β βββ _common/ <- Shared across components.
β β βββ elements/
β β β βββ buttons.tsx
β β βββ styles/
β β βββ BoxStyles.ts
β βββ pages/
β β βββ Home/
β β β βββ Home.tsx
β β β βββ Home.test.tsx
β β βββ Login/
β β βββ dialogs/
β β β βββ ResetPasswordDialog.tsx
β β βββ Login.tsx
β β βββ Login.test.tsx
β βββ App.tsx
β βββ index.css
βββ services/
βββ index.html
package.json
tsconfig.json
src/ and components/ are branch directories; Home/ and Login/ are focused directories.
A focused directory already names the feature, so we can be less strict with how we name its supporting files:
| Folder | Purpose |
|---|---|
_local/ |
Helpers used within the directory; may also be shared outward when sensible. |
_external/ |
Helpers for outside consumers, not used internally. |
_internal/ |
Implementation details not for outside use. |
Broad names like utils.ts or ui.tsx are fine inside _local/ but not in the focused directory's root:
Login/_local/ui.tsx <- Good: clearly a local helper.
Login/ui.tsx <- Avoid: its role is less clear.
The names local/internal/external communicate intent; they do not enforce access. You could still import an internal item some where else if you really need to: e.g. unit-testing.
_common/
βββ elements/
βββ DataTable/
βββ _local/
β βββ datatable-elements.tsx
βββ _external/
β βββ dataTableFilterToUrlString.ts
βββ _internal/
β βββ sortTableData.ts
βββ DataTable.tsx
βββ DataTable.test.tsx
Login/
βββ _local/
β βββ ui.tsx
β βββ constants.ts
βββ dialogs/
β βββ _local/
β β βββ AuthDialog.tsx
β βββ ForgotPasswordDialog.tsx
β βββ SignupInsteadDialog.tsx
βββ Login.tsx
βββ Login.test.tsx
datatable-elements.tsx: shared inside and outsideDataTable/.dataTableFilterToUrlString.ts: for outside consumers.sortTableData.ts: used only byDataTable.tsx.Login/_local/ui.tsx: UI shared byLoginand its dialogs.AuthDialog.tsx: common base for the two auth dialogs.
Β· Β· Β·
| Test type | What it checks |
|---|---|
| Unit | One piece of behavior in isolation. |
| Integration | Multiple units, modules, or layers together. |
| End-to-end | A full user flow through the running system. |
Boundaries vary. In backend projects, "integration test" often means calling a route and exercising several layers. See Architecture.
Cover important behavior, not every input combination. Test meaningful behavior triggered by users not ever theoretical edge case.
Developers write their own unit/integration tests, even during rapid development. Writing tests not only catches bugs but doubles as proofreading.
e2e tests can come later and/or be done by a dedicated tester They are expensive to write and maintain (e.g. Cypress).
Β· Β· Β·
TypeScript supports several styles; a project need not commit to one.
- Procedural: Functions operating on data.
- Object-oriented: Behavior organized around objects, emphasizing encapsulation, abstraction, inheritance, polymorphism.
- Functional: Composed functions, immutable data, controlled side effects. Pure FP applies these strictly.
Notes:
- Using functions instead of classes does not make code functional.
- This guide favors procedural with classes handling OOP specific features.
Β· Β· Β·
Good model documentation saves trips to the database manager to answer "what does this field reference?"
| Term | Meaning |
|---|---|
| Model layer | Code describing and working with stored data shapes. |
| Comment tag | An @ keyword inside a comment. |
| Entity type | A type representing a table row. |
| Auxiliary table | A supporting table, e.g. user_avatars for users. |
| Join table | A connecting table, e.g. projects_users. |
| Derived type | Built from an entity type with added or transformed fields. |
| Audit column | Lifecycle column such as createdAt or createdBy. |
I avoid record in type names to prevent confusion with Record<>. Join tables use both plural names: projects_users.
TypeScript already has parameter and return types; use tags only for what types cannot express.
Standard tags: @private, @param, @returns, @see.
Custom tags in this guide:
| Tag | Purpose |
|---|---|
@testOnly |
Used only by tests. |
@cronJob |
Used by scheduled jobs. |
@dummyData |
Generates or manages dummy data. |
@startupTime |
Runs during startup, not request handling. |
@entity |
The table an entity type represents. |
@auxiliaryOf |
The table an auxiliary table supports. |
@joins |
A table connected via a join table. |
@route |
A handler's HTTP method and path. |
Keep the link in the description, @private on its own line:
/**
* Prepare the values needed by the parent operation.
*
* Used by: {@link parentFunction}.
*
* @private
*/If a function is used by multiple other functions, moved each link to it's own line. Although if a private function is used by dozens (or more) of other functions this may not be practical
/**
* Prepare the values needed by the parent operation.
*
* Used by:
* {@link parentFunction1}
* {@link parentFunction2}
* {@link parentFunction3}
*
* @private
*/
---- OR
/**
* Prepare the values needed by the parent operation.
*
* Used by: {many}
*
* @private
*//**
* @entity users
*/
interface User {
name: string;
}Auxiliary table:
/**
* @entity user_avatars
* @auxiliaryOf users
*/Join table:
/**
* @entity charts_users
* @joins users
* @joins charts
*/Tags and entity types:
- Primary keys:
// @PK. - Ordinary data fields.
- Foreign keys:
// @FKplus cardinality. - Audit columns:
// @AC, unless already an FK. - Transient fields:
// @TEfor values added outside the database.
userId: number; // @FK 1-1
createdAt: Date; // @ACHere user_avatars.userId is both a FK and unique, allowing at most one avatar per user.
interface Entity {
id: number; // @PK
createdAt: Date | string; // @AC
updatedAt: Date | string; // @AC
}
/**
* @entity users
*/
interface User extends Entity {
name: string;
}
/**
* @entity user_avatars
* @auxiliaryOf users
*/
interface UserAvatar extends Entity {
filename: string | null;
userId: number; // @FK 1-1; unique in the database.
}
// Created in the service layer.
// Binary data belongs in the transfer type, not the database entity.
interface UserAvatarDTO extends UserAvatar {
data: Blob;
}
/**
* Create a user for tests.
*
* @testOnly
*/
function getDummy() {
return {
id: randomInt(10),
name: 'John',
createdAt: new Date(),
updatedAt: new Date(),
};
}Include method and path on route handlers; it makes them easy to find. Keep handlers focused on HTTP and delegate to a service.
/**
* Fetch a user's posts.
*
* @route GET /api/posts/:userId
*/
async function fetchPostsByUserId(
req: Request,
res: Response,
): Promise<void> {
const posts = await PostService.fetchPostsByUserId(
Number(req.params.userId),
);
res.json(posts);
}
// Elsewhere in the application:
app.get(
'/api/posts/:userId',
validateUserIdParam,
fetchPostsByUserId,
);validateUserIdParam rejects invalid params before the handler runs (e.g. requiring a positive safe integer). Number() converts; it does not validate. Make the validation boundary explicit.
Β· Β· Β·
Architecture should make responsibilities easy to find and dependencies easy to follow.
- Domain: A business area grouping related features, e.g.
Authwith signup, login, and password recovery. - Layer: A part of the app with one responsibility, such as handling requests or accessing stored data.
| Layer | Responsibility |
|---|---|
| Repository | Reads/writes persisted data. Suffix Repo. |
| Service | Business logic and workflow coordination, server or client. Suffix Service. |
| API client | Client-side HTTP requests and responses. Suffix Api. |
| Cron jobs | Scheduled server-side work. |
| Controller | Handles client requests and delegates. |
| Middleware | Framework-level request processing, e.g. validation. |
| Infrastructure | Wraps external systems: DB connections, storage, HTTP clients. |
With several storage systems, make repo names specific: UserRepo.ts for database records, UserAssetRepo.ts for stored files such as S3 objects.
Services coordinate persistence through repositories or adapters rather than embedding storage operations in business logic.
One service entry point per domain. Controllers call the domain's primary service, never repositories or infrastructure directly:
Controller -> Service -> Repository or infrastructure adapter -> Persistence
For small or solo projects, grouping by layer is straightforward: folder names describe technical roles and the structure is easy to explain. The cost is that related code spreads across more folders as features grow.
config/
src/
βββ assets/
βββ cronjobs/
βββ repos/
β βββ db/
β β βββ db.ts
β βββ UserRepo.ts
β βββ PostRepo.ts
βββ routes/
β βββ UserRoutes.ts
β βββ PostRoutes.ts
βββ services/
β βββ UserServices/
β β βββ UserService.ts
β β βββ UserAssets.ts
β βββ PostService.ts
βββ main.ts
βββ server.ts
tests/
βββ users.test.ts
βββ posts.test.ts
package.json
tsconfig.json
UserAssets.ts might be added later for avatar uploads. UserRepo.ts rather than user.repo.ts because these are module-object filesβsee Naming conventions.
For larger apps I prefer grouping by domain. Related layers stay together, which makes it easier to make updates as a feature grows.
Trade-offs: business-oriented names are less immediately obvious than repos/ and services/.
config/
src/
βββ _assets/
βββ _common/
βββ cronjobs/
βββ domains/
β βββ users/
β β βββ _local/
β β β βββ constants/
β β β β βββ errors.ts
β β β βββ types/
β β β βββ schemas.ts
β β βββ UserRepo.ts
β β βββ UserService.ts
β β βββ UserAssets.ts
β β βββ UserController.ts
β βββ posts/
β βββ _internal/
β β βββ PostToPDF.ts
β βββ PostRepo.ts
β βββ PostService.ts
β βββ PostController.ts
βββ infra/
β βββ db.ts
β βββ session.ts
βββ routers/
β βββ middleware/
β βββ user.router.ts
β βββ post.router.ts
β βββ api.ts
βββ main.ts
βββ server.ts
tests/
βββ users.test.ts
βββ posts.test.ts
package.json
tsconfig.json
User layers live under domains/users/, post layers under domains/posts/, shared infrastructure under infra/. PostToPDF.ts holds logic for rendering a post as a PDF.
In a layer-based layout, adding a second user service means creating UserServices/ and moving files; in a domain layout, the files already belong together.
These examples use a backend server. For the client side, see React + TypeScript Best Practices.