Backend engineeringDeveloper experience in TypeScript20 modules, team ready
DX
Unpacked
The habits that make a TypeScript backend pleasant to read, safe to change and quick to debug: one clear rule for every text case, names that explain themselves, types that catch mistakes early, errors that say what went wrong, and tooling that enforces it all for you.
Four habits
Good DX comes from four habits. Name things so they explain themselves, type them so mistakes fail early, fail in ways that are easy to debug, and let tools enforce the rest.
Names and cases that tell readers what a thing is.
Types and validation that catch mistakes early.
Errors and logs that make the cause obvious.
Tools and habits that keep the team consistent.
What good DX means
Developer experience is how quickly someone can read, change and trust your code. Most of it comes from a few boring, consistent habits.
Good DX is a well organised kitchen: every knife has its place, labels say what is in each jar, and the smoke alarm goes off before the food burns, not after the guests arrive.
Optimise for the reader, because code is read far more than written. Pick one convention and automate it rather than debating it. Push feedback left: the editor (types, lint) catches more than CI, and CI catches more than production. Make the right way the easy way with templates, scripts and strict defaults.
Pick it forSetting team standards for a new service or cleaning up an old one.
- Reader first
- Clear beats clever
- One way
- Conventions, automated
- Fast feedback
- Editor, hooks, CI
- Safe defaults
- Strict types, validation
How it flows
requestresponsepush or streamcontrol
Avoid and prefer
const d = await svc.proc(u, true, 3);What is d? What does true mean? Why 3?const invoice = await billing.createInvoice({ userId, sendEmail: true, dueInDays: 3 });Every name and argument explains itself.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Optimise for reading | Descriptive names over comments | Code is read many times, written once | Always |
Automate conventions | ESLint, Prettier, hooks | Reviews focus on logic, not style | From the first commit |
Fail early | strict types, validated config | Bugs surface at build, not in production | Every boundary |
Small, focused units | One job per function and file | Easier to test and reuse | When a unit needs "and" to describe it |
Document the why | README, ADRs, short comments | Decisions survive people leaving | Non obvious choices |
Try it
# one command a new teammate can trust
npm run check # = tsc --noEmit && eslint . && vitest run
One script, documented in the README. New joiners should be able to clone, install and run one command that proves everything works.
# a recorded session, replayed when you press Run npm run check > tsc --noEmit && eslint . && vitest run ✓ src/orders/order.service.test.ts (6 tests) 14ms ✓ src/users/user.mapper.test.ts (3 tests) 4ms Test Files 2 passed (2) Tests 9 passed (9)
Key terms
| Term | Simple meaning |
|---|---|
DX | Developer experience: how easy it is to work on the code |
Shift left | Catch problems earlier in the workflow |
Convention | A rule everyone follows so nobody has to think about it |
Cognitive load | How much a reader must hold in their head |
Text cases, side by side
The same three words, user account id, written eight ways. Each case has one job; mixing them is what makes code feel messy.
Cases are like the dress code for words. The same person wears a suit to court, a uniform at work and pyjamas at home. userAccountId, UserAccount, user_account_id and user-account-id are the same idea dressed for different places.
Cases differ in two things: how words are separated (capital letters, underscore, hyphen, dot or nothing) and whether letters are upper or lower. In a TypeScript backend you meet camelCase for values, PascalCase for types, SCREAMING_SNAKE_CASE for environment variables, snake_case in SQL, kebab-case in files and URLs, dot.case in event names and Train-Case in HTTP headers. Treat acronyms as words: userId, HttpClient, parseJson.
Pick it forEvery name you write. Look up the place, use its case.
- Values
- camelCase
- Types
- PascalCase
- Env and constants
- SCREAMING_SNAKE_CASE
- Files and URLs
- kebab-case
Text cases at a glance
userAccountIdFirst word lower, each next word starts upperVariables, functions, methods, object keys, JSON fieldsUserAccountIdEvery word starts upper, no separatorsClasses, interfaces, types, enums, decorators, React componentsuser_account_idAll lower, words joined by underscoresDatabase tables and columns, Python codeUSER_ACCOUNT_IDAll upper, words joined by underscoresEnvironment variables, true constantsuser-account-idAll lower, words joined by hyphensFile names, URL paths, CLI flags, package namesuser.account.idAll lower, words joined by dotsEvent names, config keys, metric namesUser-Account-IdEach word capitalised, joined by hyphensHTTP headers such as Content-Type, X-Request-IduseraccountidAll lower, no separatorsAvoid: hard to read; only where a system forces itAvoid and prefer
getHTTPResponse · userID · XMLParserShouting acronyms hides word boundaries.getHttpResponse · userId · XmlParserAcronyms behave like normal words.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Separator by capitals | userAccountId, UserAccountId | Valid identifiers in TypeScript | Code: values and types |
Separator by underscore | user_account_id, MAX_RETRIES | Case insensitive systems keep the words apart | SQL, env vars, constants |
Separator by hyphen | user-account-id.ts, /user-accounts | Safe in file systems and URLs | Files, routes, packages |
Acronyms as words | HttpClient, parseJson, userId | Word boundaries stay visible | Every case |
Convert at boundaries | ORM naming strategy, mappers | One case inside the app | DB rows and third party payloads |
Try it
const words = ["user", "account", "id"];
const cap = (w: string) => w[0]!.toUpperCase() + w.slice(1);
export const toCamel = (ws: string[]) => ws[0]! + ws.slice(1).map(cap).join("");
export const toPascal = (ws: string[]) => ws.map(cap).join("");
export const toSnake = (ws: string[]) => ws.join("_");
export const toScream = (ws: string[]) => ws.join("_").toUpperCase();
export const toKebab = (ws: string[]) => ws.join("-");
console.table({ camel: toCamel(words), pascal: toPascal(words), snake: toSnake(words), scream: toScream(words), kebab: toKebab(words) });
Convert at the edges, not everywhere. Keep camelCase inside TypeScript and convert once where data meets the database or an external API, for example in your ORM's naming strategy.
# a recorded session, replayed when you press Run npx tsx cases.ts ┌─────────┬────────────────────┐ │ (index) │ Values │ ├─────────┼────────────────────┤ │ camel │ 'userAccountId' │ │ pascal │ 'UserAccountId' │ │ snake │ 'user_account_id' │ │ scream │ 'USER_ACCOUNT_ID' │ │ kebab │ 'user-account-id' │ └─────────┴────────────────────┘
Key terms
| Term | Simple meaning |
|---|---|
Case | How a multi word name is written |
Separator | What marks the gap between words |
Acronym | Short form like HTTP or ID |
Boundary conversion | Changing case once where data enters or leaves |
Which case goes where
A lookup table for every place a name appears in a TypeScript backend, so nobody has to guess.
Like a map of a building with every room labelled. You do not decide which door is the kitchen each morning; you read the sign.
Code identifiers follow TypeScript norms: camelCase values, PascalCase types and classes, UPPER_CASE for module level constants. Outside code, follow the host system: SQL is snake_case, URLs and files are kebab-case, env vars are SCREAMING_SNAKE_CASE, HTTP headers are Train-Case, and JSON keys match your API style (camelCase is the common choice for TypeScript APIs). Enforce the code side with @typescript-eslint/naming-convention.
Pick it forSettling style debates once and for all.
- Code values
- camelCase
- Code types
- PascalCase
- Outside code
- Follow the system's own norm
- Enforce
- @typescript-eslint/naming-convention
Avoid and prefer
const User_Count = 0; class order_service {}Mixed cases make names look like typos.const userCount = 0; class OrderService {}Values camelCase, classes PascalCase.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Variables, params, functions | orderTotal, calculateTax() | TypeScript and JavaScript norm | All runtime values |
Classes, types, interfaces, enums | OrderService, CreateOrderDto, OrderStatus | Signals a type or constructor | Anything you can use as a type |
Module constants | MAX_RETRIES, DEFAULT_PAGE_SIZE | Shows it never changes | Fixed values known at build time |
Files and folders | order.service.ts, payment-gateway/ | Case safe across macOS, Linux and Windows | Every file |
Env vars | DATABASE_URL, JWT_SECRET | Shell and OS convention | Process configuration |
SQL | order_items.created_at | Postgres folds unquoted names to lower case | Tables, columns, indexes |
Try it
export default tseslint.config({
rules: {
"@typescript-eslint/naming-convention": ["error",
{ selector: "default", format: ["camelCase"] },
{ selector: "variable", modifiers: ["const", "global"], format: ["camelCase", "UPPER_CASE"] },
{ selector: "typeLike", format: ["PascalCase"] },
{ selector: "enumMember", format: ["PascalCase"] },
{ selector: "objectLiteralProperty", format: null }, // DB rows, headers
{ selector: "parameter", modifiers: ["unused"], format: ["camelCase"], leadingUnderscore: "allow" },
],
},
});
Let the linter enforce it. A written guide gets forgotten; a lint rule fails the build. Allow exceptions only where outside systems force a case.
# a recorded session, replayed when you press Run npx eslint src src/orders/order.service.ts 12:9 error Variable name `User_Count` must match one of the following formats: camelCase, UPPER_CASE @typescript-eslint/naming-convention 20:7 error Class name `order_service` must match one of the following formats: PascalCase @typescript-eslint/naming-convention ✖ 2 problems (2 errors, 0 warnings)
Key terms
| Place | Case | Example |
|---|---|---|
Variable, function, method | camelCase | createOrder |
Class, interface, type, enum | PascalCase | OrderStatus |
Enum member | PascalCase | OrderStatus.Paid |
Constant | SCREAMING_SNAKE_CASE | MAX_RETRIES |
Env variable | SCREAMING_SNAKE_CASE | DATABASE_URL |
File, folder, URL path | kebab-case | /order-items |
DB table, column | snake_case | created_at |
JSON field | camelCase | createdAt |
HTTP header | Train-Case | X-Request-Id |
Event, queue, metric | dot.case | order.created |
Naming variables and booleans
A good variable name says what it holds, in what unit, and for booleans, what question it answers.
Labels on storage boxes. "Stuff" helps nobody; "Winter jackets, kids, 2025" tells you what is inside without opening it.
Use nouns for values and plurals for collections (orders, orderById for maps). Booleans read as yes or no questions with is, has, can, should or did. Put units in the name when the type cannot (timeoutMs, priceInPaise, sizeBytes). Avoid abbreviations except universal ones (id, url, db), single letters except tiny loops, and noise words such as data, info, temp or obj. Name length should grow with scope.
Pick it forEvery variable, property and parameter.
- Values
- Nouns: invoice, retryCount
- Collections
- Plurals: invoices
- Booleans
- isPaid, hasAccess, canRetry
- Units
- timeoutMs, amountInPaise
Avoid and prefer
const flag = true; const t = 5000; const data = await get();Which flag? 5000 what? What data?const isEmailVerified = true; const timeoutMs = 5000; const pendingOrders = await getPendingOrders();Readable without opening the function.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Boolean prefix | isPaid, hasAccess, canRetry, shouldNotify | Reads like a yes or no question | Every boolean |
Plural collections | users, orderIds | Shows it holds many | Arrays and sets |
Maps named by key | userById, priceBySku | Says how to look things up | Maps and records |
Units in names | delayMs, sizeBytes, amountInPaise | Prevents unit mix ups | Numbers with units |
Scope sized names | i in a 3 line loop, activeSubscriptionCount at module level | Short where obvious, long where far away | Always |
Try it
// collections and lookups
const orders: Order[] = await orderRepo.findPending();
const orderById = new Map(orders.map((o) => [o.id, o]));
// booleans answer a question
const isOverdue = order.dueAt < now;
const hasPaymentMethod = customer.paymentMethods.length > 0;
const canRetry = attempt < MAX_RETRIES;
// units in the name
const timeoutMs = 5_000;
const amountInPaise = 149_900;
const maxUploadBytes = 10 * 1024 * 1024;
Avoid negative booleans. if (!isNotActive) makes readers stop and think. Name the positive form: isActive.
# a recorded session, replayed when you press Run npx eslint src/billing src/billing/retry.ts 8:7 error Variable name `active` trimmed as `active` must have one of the following prefixes: is, should, has, can, did, will @typescript-eslint/naming-convention ✖ 1 problem (1 error, 0 warnings)
Key terms
| Term | Simple meaning |
|---|---|
Predicate | A boolean that answers a question |
Noise word | A word that adds nothing: data, info, item |
Scope | Where a name is visible |
Numeric separator | 5_000 is 5000, easier to read |
Naming functions and methods
Functions do things, so they start with a verb. The verb also tells the reader the cost and the side effects.
A to do list item. "Invoice" is vague; "send the invoice to the customer" tells you exactly what will happen when you tick it.
Use verbNoun in camelCase. Pick verbs consistently across the codebase: get for cheap in memory reads, find for queries that may return nothing, fetch or load for network or database calls, create, update, delete or remove for writes, build or to for pure transforms, and is, has or can for predicates. Event handlers are handleX or onX. A name that needs "and" is two functions.
Pick it forEvery function, method and service API.
- Shape
- verbNoun: createInvoice
- May be empty
- findUserByEmail → User | undefined
- Network or DB
- fetch, load
- Pure transform
- toDto, buildQuery, formatAmount
Avoid and prefer
function user(e) { … } · processData() · doStuff()No verb, or a verb that says nothing.findUserByEmail(email) · chargeCustomer() · toInvoiceDto(order)The verb promises the behaviour.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
get | getById, getConfig | Returns or throws; never undefined | The thing must exist |
find | findByEmail, findActive | Returns T | undefined or [] | Lookups that may miss |
create, update, delete | createOrder, updateAddress | Clear write intent | Mutations |
to, build, format | toDto, buildWhereClause, formatInr | Pure, no side effects | Transforms |
is, has, can | isRefundable(order) | Returns boolean | Predicates and guards |
handle, on | handlePaymentCaptured, onShutdown | Reacts to an event | Listeners, consumers |
Try it
export class OrderService {
// may return nothing: find
async findById(id: OrderId): Promise<Order | undefined> { … }
// must exist or throw: get
async getById(id: OrderId): Promise<Order> {
const order = await this.findById(id);
if (!order) throw new OrderNotFoundError(id);
return order;
}
async createOrder(input: CreateOrderInput): Promise<Order> { … }
async cancelOrder(id: OrderId, reason: CancelReason): Promise<void> { … }
}
// pure helpers and predicates
export const toOrderDto = (order: Order): OrderDto => ({ … });
export const isRefundable = (order: Order): boolean => order.status === OrderStatus.Paid;
find may miss, get must hit. Using the pair consistently lets callers know from the name whether they must handle undefined.
# a recorded session, replayed when you press Run npx tsc --noEmit src/orders/order.controller.ts:18:22 - error TS18048: 'order' is possibly 'undefined'. 18 return toOrderDto(order); ~~~~~ # findById can miss: switch to getById or handle undefined Found 1 error in src/orders/order.controller.ts:18
Key terms
| Term | Simple meaning |
|---|---|
Side effect | Anything a function changes outside itself |
Pure function | Same input, same output, no side effects |
Predicate | A function that returns true or false |
Guard | A check that narrows a type or stops early |
Constants, enums and magic values
Give every meaningful literal a name, keep fixed sets of values typed, and let the compiler check you handled every case.
A magic number is a sticky note that just says "42". A constant is the same note saying "maximum seats per table: 42". One you can trust; the other you must investigate.
Name literals with business meaning (MAX_RETRIES, DEFAULT_PAGE_SIZE) in SCREAMING_SNAKE_CASE at module level. For fixed sets, prefer an as const object plus a derived union type, which has no runtime surprises and works with plain strings from JSON; TypeScript enums are fine in NestJS codebases that already use them, but avoid numeric enums. Use a never check in switch statements so adding a value breaks the build where it is not handled.
Pick it forStatus values, limits, timeouts, roles and every repeated literal.
- Constant
- SCREAMING_SNAKE_CASE
- Fixed sets
- as const object + union
- Enum members
- PascalCase
- Exhaustive
- never check in switch
Avoid and prefer
if (retries > 3) … if (status === 'paid') …Magic values repeated, typos compile.if (retries > MAX_RETRIES) … if (status === OrderStatus.Paid) …Named once, checked by the compiler.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Module constant | export const MAX_RETRIES = 3 | Named, searchable, one place to change | Limits, defaults, timeouts |
as const object | { Paid: "paid" } as const | Plain values, derived union type | Status, roles, kinds |
String union | type Role = "admin" | "member" | Lightest option, no runtime object | Small sets used only in types |
TypeScript enum | enum OrderStatus { Paid = "paid" } | Familiar, works with NestJS Swagger | Codebases already using enums; string values only |
Exhaustive switch | const x: never = value | Compiler finds missing cases | Every switch over a union |
Try it
export const MAX_RETRIES = 3;
export const DEFAULT_PAGE_SIZE = 20;
export const OrderStatus = {
Pending: "pending",
Paid: "paid",
Shipped: "shipped",
} as const;
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
export function statusLabel(status: OrderStatus): string {
switch (status) {
case OrderStatus.Pending: return "Awaiting payment";
case OrderStatus.Paid: return "Paid";
case OrderStatus.Shipped: return "On the way";
default: { const unhandled: never = status; throw new Error(`Unhandled status ${unhandled}`); }
}
}
The never check is free insurance. Add Refunded to OrderStatus and every switch that forgot it fails to compile, instead of silently returning undefined.
# a recorded session, replayed when you press Run # after adding Refunded: "refunded" to OrderStatus npx tsc --noEmit src/orders/order-status.ts:16:24 - error TS2322: Type '"refunded"' is not assignable to type 'never'. Found 1 error in src/orders/order-status.ts:16
Key terms
| Term | Simple meaning |
|---|---|
Magic number | An unexplained literal in code |
as const | Makes values read only and keeps their exact literal types |
Union type | A value that is one of a fixed set |
Exhaustive check | Proof every option was handled |
Types, interfaces and generics
Name types for what they model, keep DTOs separate from domain types, and let unions model states instead of optional fields.
A type is the shape of a mould. A clear mould (PaymentMethod is either a card or UPI) makes impossible shapes impossible, instead of one blob with every field optional.
Use PascalCase with no I or T prefixes (User, not IUser). interface suits object shapes that classes implement or that may be extended; type suits unions, mapped and utility types; pick one default per team. Suffix boundary types by role: CreateOrderDto, OrderResponse, OrderRow. Model states with discriminated unions on a kind or status field. Generic parameters are T for one, or descriptive with a T prefix such as TItem, TKey when there are several.
Pick it forEvery data shape, especially at API and database boundaries.
- Names
- PascalCase, no I prefix
- Boundary types
- Dto, Response, Row suffixes
- States
- Discriminated unions
- Generics
- T, or TItem, TKey
Avoid and prefer
interface IPayment { card?: string; vpa?: string; type: string }Every field optional, any combination allowed.type PaymentMethod =
| { kind: "card"; last4: string }
| { kind: "upi"; vpa: string }Only valid combinations exist.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
interface | interface UserRepository { … } | Extendable object contracts | Shapes classes implement |
type alias | type Id = string; type Result = A | B | Unions and computed types | Everything else |
Discriminated union | { kind: "card" } | { kind: "upi" } | Impossible states cannot be built | Variants and lifecycle states |
Utility types | Pick, Omit, Partial, Readonly, Record | Derive types instead of copying | DTOs and views |
Generics | Repository<TEntity, TId> | Reusable and still typed | Shared helpers and base classes |
Try it
export type PaymentMethod =
| { kind: "card"; last4: string; network: "visa" | "rupay" }
| { kind: "upi"; vpa: string };
export function describe(method: PaymentMethod): string {
switch (method.kind) {
case "card": return `${method.network} ending ${method.last4}`; // narrowed
case "upi": return method.vpa;
}
}
export interface Repository<TEntity, TId> {
findById(id: TId): Promise<TEntity | undefined>;
save(entity: TEntity): Promise<void>;
}
export type OrderSummary = Pick<Order, "id" | "status" | "total">;
Derive, do not duplicate. Pick, Omit, Partial and z.infer build new types from existing ones, so a field rename updates everywhere.
# a recorded session, replayed when you press Run npx tsc --noEmit src/payments/receipt.ts:9:27 - error TS2339: Property 'last4' does not exist on type 'PaymentMethod'. Property 'last4' does not exist on type '{ kind: "upi"; vpa: string; }'. # check method.kind === "card" first, then last4 is available
Key terms
| Term | Simple meaning |
|---|---|
DTO | Data transfer object: the shape that crosses a boundary |
Discriminant | The field that tells union members apart |
Narrowing | TypeScript learning a more exact type after a check |
Generic | A type with a placeholder filled in later |
Typed arguments, params and returns
Type every boundary explicitly, prefer one options object over long argument lists, and make invalid calls fail to compile.
A form with labelled boxes beats a row of unlabelled ones. createUser("Asha", true, false, 3) is a row of blanks; createUser({ name: "Asha", isAdmin: true }) is a filled form.
Exported functions get explicit parameter and return types, which documents the contract and stops accidental changes. With more than two parameters, or any boolean flag, take a single named options object with defaults. Mark inputs readonly when you do not mutate them. Use unknown, never any, for truly unknown data and narrow it. Brand ids (type OrderId = string & { readonly __brand: "OrderId" }) so a userId cannot be passed as an orderId.
Pick it forEvery exported function, service method and public API.
- Exports
- Explicit param and return types
- Many args
- One options object
- Unknown input
- unknown, then narrow
- Ids
- Branded types
Avoid and prefer
sendEmail(to, true, false, 3)What do true, false and 3 mean?sendEmail({ to, isTransactional: true, trackOpens: false, retryCount: 3 })Self describing and order free.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Explicit return type | function total(): Money | Contract cannot drift by accident | Exported functions |
Options object | fn({ a, b, c }) | Named, optional, order independent | More than 2 params or any boolean |
Defaults in destructuring | { limit = 20 }: Options | Defaults visible in the signature | Optional settings |
readonly inputs | readonly items: readonly Item[] | Signals no mutation | Arrays and objects passed in |
Branded ids | type OrderId = Brand<string, "OrderId"> | Stops mixing ids of the same base type | Entity ids, money, units |
unknown at edges | parse(body: unknown) | Forces validation | Request bodies, JSON, catch values |
Try it
type Brand<T, B extends string> = T & { readonly __brand: B };
export type UserId = Brand<string, "UserId">;
export interface SendEmailOptions {
readonly to: string;
readonly templateId: string;
readonly variables?: Readonly<Record<string, string>>;
readonly retryCount?: number;
}
export async function sendEmail(
{ to, templateId, variables = {}, retryCount = 2 }: SendEmailOptions,
): Promise<{ messageId: string }> {
…
}
export function parseWebhook(body: unknown): PaymentEvent {
return paymentEventSchema.parse(body); // narrow unknown with validation
}
unknown forces a check; any switches checks off. any spreads silently through everything it touches. unknown makes you prove the type before use.
# a recorded session, replayed when you press Run npx tsc --noEmit src/users/user.service.ts:22:31 - error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'. Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'. npx eslint src 14:17 error Missing return type on function @typescript-eslint/explicit-module-boundary-types
Key terms
| Term | Simple meaning |
|---|---|
Parameter | The name in the function definition |
Argument | The value you pass when calling |
Branded type | A primitive tagged so similar values cannot mix |
any vs unknown | any turns checks off; unknown requires a check |
Files, folders and modules
Group code by feature, name files by role in kebab-case, and keep imports short and predictable.
Organise a house by room, not by object type. All the kitchen things live in the kitchen; you do not keep every spoon in the house in one "spoons" room.
Use feature folders (orders/, payments/) rather than layer folders (controllers/, services/) once a service grows. Files are kebab-case with a role suffix, matching NestJS style: order.controller.ts, order.service.ts, create-order.dto.ts, order.service.test.ts. One main export per file, named like the file. Keep barrel index.ts files to public module boundaries only, since deep barrels slow builds and cause circular imports. Use path aliases for long relative paths.
Pick it forAny service bigger than a handful of files.
- Files
- kebab-case + role suffix
- Folders
- By feature
- Tests
- Next to code, .test.ts
- Imports
- Path aliases, few barrels
Avoid and prefer
src/controllers/OrderController.ts · src/services/orderSvc.tsScattered by layer, mixed cases.src/orders/order.controller.ts · src/orders/order.service.tsEverything about orders in one place.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Role suffix | *.controller.ts, *.service.ts, *.dto.ts | Find files by what they do | Every file |
Feature folders | src/orders/, src/payments/ | Changes stay in one folder | Services past a few modules |
Colocated tests | order.service.test.ts beside the code | Easy to find and keep in sync | Unit tests |
Path aliases | import { AppError } from "@common/errors" | No ../../../ chains | Deep trees |
Public barrels only | orders/index.ts exports the module API | Clear boundaries, no cycles | Module entry points |
Try it
src/
orders/
order.controller.ts
order.service.ts
order.repository.ts
order.module.ts
dto/create-order.dto.ts
order.service.test.ts
payments/
payment-gateway.client.ts
common/
errors/app-error.ts
config/
env.ts
main.ts
Case sensitive file systems bite. OrderService.ts and orderService.ts are the same file on macOS and different files on Linux CI. kebab-case avoids it.
# a recorded session, replayed when you press Run git mv src/services/OrderService.ts src/orders/order.service.ts grep -n '"paths"' -A 3 tsconfig.json "paths": { "@orders/*": ["src/orders/*"], "@common/*": ["src/common/*"] }
Key terms
| Term | Simple meaning |
|---|---|
Barrel file | An index.ts that re-exports other files |
Path alias | A short import prefix mapped in tsconfig |
Colocation | Keeping related files side by side |
Circular import | Two files importing each other, often breaking at runtime |
A strict tsconfig
A handful of compiler flags catch whole classes of bugs before code runs. Turn them on at the start; adding them later is painful.
Compiler flags are the smoke alarms in your house. Each one is cheap to install, slightly annoying when it beeps, and far cheaper than the fire it prevents.
strict enables strictNullChecks, noImplicitAny, useUnknownInCatchVariables and more. Add noUncheckedIndexedAccess so array and record lookups include undefined, exactOptionalPropertyTypes so optional does not secretly accept undefined, noImplicitOverride, noFallthroughCasesInSwitch and verbatimModuleSyntax for clean imports. Use module and moduleResolution NodeNext for Node backends. Run tsc --noEmit in CI.
Pick it forEvery new project; ratchet older ones one flag at a time.
- Baseline
- "strict": true
- Indexing
- noUncheckedIndexedAccess
- Modules
- NodeNext
- CI
- tsc --noEmit
Avoid and prefer
const first = users[0]; first.emailCompiles, then crashes on an empty list.const first = users[0]; if (!first) return; first.emailThe compiler made you handle empty.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
strict | "strict": true | Null checks, no implicit any, unknown in catch | Always |
noUncheckedIndexedAccess | arr[0] is T | undefined | Empty arrays and missing keys are handled | Always for new code |
exactOptionalPropertyTypes | a?: string rejects undefined | Optional means absent, not undefined | APIs where the difference matters |
noImplicitOverride | override keyword required | Catches renamed base methods | Class hierarchies |
verbatimModuleSyntax | import type { X } | Predictable emitted imports | ESM projects |
Try it
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"outDir": "dist"
}
}
NestJS needs two extras. Decorators require experimentalDecorators and emitDecoratorMetadata in NestJS projects; keep the strict flags alongside them.
# a recorded session, replayed when you press Run npx tsc --noEmit src/users/user.service.ts:31:12 - error TS18048: 'first' is possibly 'undefined'. 31 return first.email; ~~~~~ Found 1 error in src/users/user.service.ts:31
Key terms
| Term | Simple meaning |
|---|---|
strictNullChecks | null and undefined must be handled explicitly |
noImplicitAny | Untyped values are errors, not silently any |
NodeNext | Module rules that match how Node.js loads files |
Ratchet | Tightening one rule at a time, never loosening |
Validate at the boundaries
Types disappear at runtime. Parse everything that enters the system once, at the edge, and trust the typed result inside.
A security check at the airport entrance. Everyone is checked once at the door, so nobody needs to be checked again at every gate.
Request bodies, query strings, headers, webhooks, queue messages, env vars and third party responses are unknown until parsed. Define a schema once (Zod, or class-validator DTOs in NestJS), parse at the edge, and derive the TypeScript type from the schema with z.infer so they never drift. Return 400 with field level messages. Coerce query strings deliberately, and strip unknown keys.
Pick it forEvery place data enters your process.
- Where
- Every input boundary
- How
- Zod schema or NestJS DTO + ValidationPipe
- Types
- z.infer from the schema
- Bad input
- 400 with field details
How it flows
requestresponsepush or streamcontrol
Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Zod schema | z.object({ … }) | Runtime check plus inferred type | Express, Fastify, workers, scripts |
NestJS DTO | class-validator + ValidationPipe({ whitelist: true }) | Framework native, Swagger friendly | NestJS controllers |
safeParse | schema.safeParse(input) | No throw, explicit branch | Request handlers |
Coercion | z.coerce.number() for query strings | Query values arrive as strings | Pagination, filters |
Validate outbound too | parse third party API responses | Their contract can change | Payment gateways, partner APIs |
Try it
import { z } from "zod";
export const createOrderSchema = z.object({
customerId: z.string().uuid(),
amount: z.number().int().positive(),
currency: z.enum(["INR", "USD"]).default("INR"),
note: z.string().max(200).optional(),
});
export type CreateOrderInput = z.infer<typeof createOrderSchema>;
app.post("/orders", async (req, res) => {
const parsed = createOrderSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "VALIDATION_FAILED", details: parsed.error.issues });
}
res.status(201).json(await orders.createOrder(parsed.data));
});
One schema, two jobs. The schema checks data at runtime and produces the type at compile time, so the two can never disagree.
# a recorded session, replayed when you press Run curl -s -X POST localhost:3000/orders -H 'Content-Type: application/json' -d '{"customerId":"c1","amount":"abc"}' | jq '.details[] | {path, message}' { "path": ["customerId"], "message": "Invalid UUID" } { "path": ["amount"], "message": "Invalid input: expected number, received string" }
Key terms
| Term | Simple meaning |
|---|---|
Boundary | Where outside data enters your code |
Parse, don't validate | Turn unknown data into a typed value once |
Schema | A description of valid data that code can check |
Whitelist | Drop properties the schema does not mention |
try / catch done right
Catch only where you can do something useful, treat the caught value as unknown, add context, and never swallow an error silently.
A goalkeeper should catch the ball only when they can do something with it. Catching every shot and quietly hiding it behind the goal just means nobody knows the score.
Under strict, the catch variable is unknown: narrow with instanceof before reading .message. Catch at a level that can recover, retry, translate or add context; otherwise let it propagate to one global handler. When rethrowing, wrap with new Error(msg, { cause }) so the original stack survives. Use finally for cleanup such as releasing a DB client. Log an error once, where it is handled, not at every layer.
Pick it forEvery external call: database, network, file system, parsing.
- Caught type
- unknown under strict
- Rethrow
- new AppError(msg, { cause })
- Cleanup
- finally
- Log
- Once, where handled
How it flows
requestresponsepush or streamcontrol
Avoid and prefer
try { await charge() } catch (e) { console.log(e) }Swallowed: the caller thinks payment worked.catch (err) { throw new PaymentGatewayError("Charge failed", { cause: err }) }Context added, original error kept.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Catch to recover | retry, fallback, default value | The error is fully handled | Transient, expected failures |
Catch to translate | throw new DomainError(…, { cause }) | Callers see meaningful errors | Library and network errors |
Let it propagate | no try/catch | One handler decides the response | When you cannot do anything useful |
finally | release(), clearTimeout() | Cleanup runs on every path | Resources and locks |
Narrow unknown | err instanceof AppError | Safe property access | Every catch block |
Try it
export async function charge(order: Order): Promise<ChargeResult> {
const client = await pool.connect();
try {
const res = await gateway.createCharge({ amount: order.amount, idempotencyKey: order.id });
await client.query("UPDATE orders SET status = 'paid' WHERE id = $1", [order.id]);
return res;
} catch (err: unknown) {
if (err instanceof GatewayTimeoutError) {
throw new PaymentGatewayError(`Charge timed out for ${order.id}`, { cause: err });
}
throw err; // unknown failure: let the global handler decide
} finally {
client.release(); // always runs
}
}
instanceof before .message. Anything can be thrown, even a string. Narrow first, or use a helper that turns unknown into an Error.
# a recorded session, replayed when you press Run node dist/scripts/charge-one.js o_981 PaymentGatewayError: Charge timed out for o_981 at charge (/app/dist/payments/payment.client.js:12:13) { [cause]: GatewayTimeoutError: connect ETIMEDOUT 10.0.0.4:443 at TLSSocket.<anonymous> (/app/dist/payments/gateway.js:41:19) }
Key terms
| Term | Simple meaning |
|---|---|
Swallowing | Catching an error and doing nothing useful with it |
cause | The original error attached to a new one |
Propagate | Let the error travel up to a caller |
Global handler | One place that turns errors into responses |
Custom errors and HTTP mapping
A small family of typed errors, with codes, mapped to HTTP responses in exactly one place.
Hospital triage tags. Each problem gets a coloured tag (not found, invalid, conflict) at the source, and the front desk reads the tag to decide what to tell the visitor.
Create a base AppError with a stable code, an HTTP status and safe details, then subclasses such as NotFoundError, ConflictError and ValidationError. Services throw domain errors and never know about HTTP. One exception filter (NestJS) or error middleware (Express) maps them to responses, ideally Problem Details (RFC 9457), and returns a generic 500 for anything unknown while logging it in full. For expected outcomes you prefer not to throw, a Result type works well.
Pick it forAny API with more than a couple of endpoints.
- Base
- AppError with code and status
- Throw from
- Services, no HTTP knowledge
- Map in
- One filter or middleware
- Format
- application/problem+json
How it flows
requestresponsepush or streamcontrol
Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Base AppError | abstract class with code + status | Consistent shape for every error | Every service |
Domain subclasses | NotFound, Conflict, Forbidden | Readable throws, typed catches | Expected business failures |
Single mapper | @Catch(AppError) filter or middleware | HTTP rules live in one place | Every API |
Problem Details | application/problem+json | Standard error body | Public APIs |
Result type | { ok: true, value } | { ok: false, error } | Failures visible in the signature | Expected outcomes in hot paths |
Try it
export abstract class AppError extends Error {
abstract readonly code: string;
abstract readonly status: number;
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
}
}
export class OrderNotFoundError extends AppError {
readonly code = "ORDER_NOT_FOUND";
readonly status = 404;
constructor(readonly orderId: string) { super(`Order ${orderId} not found`); }
}
// NestJS: one filter maps every AppError
@Catch(AppError)
export class AppErrorFilter implements ExceptionFilter {
catch(err: AppError, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>();
res.status(err.status).type("application/problem+json")
.json({ type: `https://errors.example.com/${err.code}`, title: err.message, status: err.status });
}
}
Codes are for machines, messages for humans. Clients branch on ORDER_NOT_FOUND, which never changes, not on the message text, which you may reword.
# a recorded session, replayed when you press Run curl -si localhost:3000/orders/o_404 HTTP/1.1 404 Not Found Content-Type: application/problem+json; charset=utf-8 {"type":"https://errors.example.com/ORDER_NOT_FOUND","title":"Order o_404 not found","status":404}
Key terms
| Term | Simple meaning |
|---|---|
Error code | A stable string that identifies the error kind |
Exception filter | NestJS class that turns errors into responses |
Problem Details | RFC 9457 standard JSON for HTTP errors |
Result type | Return success or failure instead of throwing |
Async and promises
Await every promise, run independent work in parallel, and put a timeout on anything that waits on the network.
Cooking a meal. Boil the pasta and fry the sauce at the same time (parallel), never walk away from the stove forever (timeouts), and never forget a pot on the burner (floating promises).
A promise that is neither awaited nor handled is a floating promise: its error becomes an unhandled rejection that can crash Node. Turn on @typescript-eslint/no-floating-promises and no-misused-promises. Use Promise.all for independent work that must all succeed, Promise.allSettled when partial results are fine, and AbortSignal.timeout(ms) with fetch. Avoid await inside loops when items are independent; cap concurrency for large batches.
Pick it forEvery handler, job and script that calls databases or APIs.
- Parallel
- Promise.all
- Partial OK
- Promise.allSettled
- Timeout
- AbortSignal.timeout(ms)
- Lint
- no-floating-promises
How it flows
requestresponsepush or streamcontrol
Avoid and prefer
for (const id of ids) { await sendEmail(id) } · sendEmail(user)Slow serial loop; a floating promise.await Promise.all(ids.map(sendEmail)) · await sendEmail(user)Parallel, and every promise awaited.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Promise.all | await Promise.all([a(), b()]) | Parallel; fails fast on the first error | Independent work that must all succeed |
Promise.allSettled | await Promise.allSettled(list) | Every result, success or failure | Batches where partial success is fine |
Timeouts | AbortSignal.timeout(2_000) | No request waits forever | Every outbound network call |
Limited concurrency | p-limit, or chunks of 10 | Protects databases and rate limits | Large batches |
void for fire and forget | void track(event) | Intent is explicit | Non critical side work that handles its own errors |
Try it
export async function loadCheckout(userId: UserId): Promise<Checkout> {
const [user, cart, prices] = await Promise.all([
users.getById(userId),
carts.getForUser(userId),
fetch(`${PRICING_URL}/prices?user=${userId}`, { signal: AbortSignal.timeout(2_000) })
.then((r) => r.json() as Promise<PriceList>),
]);
return buildCheckout(user, cart, prices);
}
// fan out with partial failure allowed
const results = await Promise.allSettled(webhooks.map((w) => deliver(w)));
const failed = results.filter((r) => r.status === "rejected").length;
// intentionally fire and forget: say so
void analytics.track("checkout_viewed", { userId });
void marks a deliberate fire and forget. It tells readers and the linter you meant it. Make sure that promise handles its own errors.
# a recorded session, replayed when you press Run npx eslint src/checkout src/checkout/checkout.ts 22:3 error Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator @typescript-eslint/no-floating-promises ✖ 1 problem (1 error, 0 warnings)
Key terms
| Term | Simple meaning |
|---|---|
Floating promise | A promise nobody awaits or handles |
Unhandled rejection | An async error nobody caught |
Concurrency limit | Maximum tasks running at once |
AbortSignal | A standard way to cancel async work |
API routes and database naming
Outside TypeScript, follow each system's own conventions: kebab-case plural routes, camelCase JSON, snake_case SQL.
When you visit another country, you drive on their side of the road. URLs, JSON and SQL each have their own road rules; your TypeScript habits stay at home.
Routes are lowercase kebab-case plural nouns with ids in the path (/v1/user-accounts/42/payment-methods); verbs come from HTTP methods, not URLs. Query params and JSON fields are camelCase in TypeScript APIs; pick one style and never mix. SQL tables and columns are snake_case (Postgres folds unquoted identifiers to lowercase); name timestamps created_at and updated_at, foreign keys user_id, and indexes idx_table_columns. Map snake_case rows to camelCase objects in the repository layer.
Pick it forDesigning endpoints, schemas and migrations.
- Routes
- /v1/order-items
- JSON
- camelCase
- SQL
- snake_case
- Migrations
- 20261006_add_order_items.sql
Avoid and prefer
POST /createOrder · GET /Orders/getById?ID=42 · column createdAtVerbs in URLs, mixed cases, quoted SQL names.POST /v1/orders · GET /v1/orders/42 · column created_atEach system's own convention.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Plural resource paths | /v1/orders, /v1/orders/42 | Predictable REST URLs | Every endpoint |
kebab-case segments | /payment-methods | URLs are case insensitive in practice and readable | Multi word resources |
camelCase JSON | { "createdAt": … } | Matches TypeScript objects, no mapping | Request and response bodies |
snake_case SQL | created_at, customer_id | No quoting needed in Postgres | Tables, columns, indexes |
Timestamped migrations | 20261006090000_add_refunds.sql | Ordered, conflict free | Every schema change |
Try it
// SQL stays snake_case
const row = await sql<OrderRow>`
SELECT id, customer_id, total_paise, created_at
FROM orders WHERE id = ${id}`;
// map once at the boundary to camelCase
export const toOrder = (r: OrderRow): Order => ({
id: r.id as OrderId,
customerId: r.customer_id,
totalInPaise: r.total_paise,
createdAt: r.created_at,
});
// routes: plural, kebab-case, versioned
router.get("/v1/order-items/:orderItemId", getOrderItem);
ORMs can map cases for you. Prisma @map, TypeORM naming strategies and Drizzle column names keep SQL snake_case while your code stays camelCase.
# a recorded session, replayed when you press Run curl -s 'localhost:3000/v1/orders?customerId=c_7&pageSize=2' | jq '.items[0]' { "id": "o_981", "customerId": "c_7", "totalInPaise": 149900, "createdAt": "2026-10-03T09:10:00Z" } psql -c '\d orders' | head -6 Column | Type | Nullable id | text | not null customer_id | text | not null total_paise | bigint | not null created_at | timestamp with time zone | not null
Key terms
| Term | Simple meaning |
|---|---|
Resource | The thing a URL names, such as an order |
Path parameter | A value inside the URL, like :orderId |
Foreign key | A column pointing to another table, like user_id |
Migration | A versioned script that changes the schema |
Configuration and environment
Read environment variables in one module, validate them at startup, and fail fast with a clear message when something is missing.
A pilot's preflight checklist. Every gauge is checked before take off, so you never discover the missing fuel at 30,000 feet.
Env names are SCREAMING_SNAKE_CASE with a clear prefix per concern (DATABASE_URL, REDIS_URL, JWT_SECRET, PAYMENT_API_KEY). Parse process.env once with a schema, coerce types (PORT to a number), apply defaults, and export a typed, frozen config object. Never read process.env elsewhere. Commit .env.example with every key and no secrets; keep real secrets in a secret manager. NestJS ConfigModule supports a validate function for this.
Pick it forEvery service, from the first commit.
- Names
- SCREAMING_SNAKE_CASE
- Read
- Once, in config/env.ts
- Validate
- At startup, exit on error
- Share
- .env.example, no secrets
How it flows
requestresponsepush or streamcontrol
Avoid and prefer
const port = process.env.PORT || 3000; // in 9 filesUntyped strings, scattered, silently defaulted.import { env } from "@config/env"; app.listen(env.PORT);One validated, typed source.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Single config module | import { env } from "@config/env" | Typed, testable, one source | Every service |
Schema validation | z.object({ … }).safeParse(process.env) | Clear errors at boot | Startup |
Coercion | z.coerce.number() for PORT | Env values are always strings | Numbers and booleans |
Prefixed names | PAYMENT_API_KEY, PAYMENT_TIMEOUT_MS | Grouped and self describing | Many related settings |
.env.example | KEY=placeholder | New joiners know what to set | Committed to the repo |
Try it
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment:", z.prettifyError(parsed.error));
process.exit(1);
}
export const env = Object.freeze(parsed.data);
Secrets never go in .env.example. It documents the keys with placeholder values; real values live in your secret manager or CI variables.
# a recorded session, replayed when you press Run node dist/main.js Invalid environment: ✖ Invalid input: expected string, received undefined → at DATABASE_URL ✖ Too small: expected string to have >=32 characters → at JWT_SECRET echo $? 1
Key terms
| Term | Simple meaning |
|---|---|
Environment variable | A setting passed to the process from outside |
Fail fast | Stop immediately when something essential is wrong |
Coercion | Converting a string into the right type |
Secret manager | A secure store for passwords and keys |
Logging that helps at 2 a.m.
Structured JSON logs with consistent fields, a request id on every line, the right level, and no secrets.
A ship's logbook. Each entry has the time, who wrote it and what happened, in the same format every time, so anyone can reconstruct the voyage after a storm.
Log objects, not sentences, with a fast JSON logger such as pino. Attach a requestId (from X-Request-Id or generated) to every log in a request via a child logger or AsyncLocalStorage, and return it in the response so support can find it. Use levels deliberately: error needs action, warn is unusual but handled, info records business events, debug is for development. Redact tokens, passwords and personal data. Log an error once with its stack and cause.
Pick it forEvery service that runs anywhere but your laptop.
- Format
- JSON, one object per line
- Correlation
- requestId on every line
- Levels
- error · warn · info · debug
- Never log
- Secrets, tokens, full card or ID numbers
How it flows
requestresponsepush or streamcontrol
Avoid and prefer
console.log("error!!", err); console.log("user", user)Unsearchable text, may leak personal data.logger.error({ err, orderId, requestId }, "charge failed")Structured, searchable, safe.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Structured logs | logger.info({ orderId }, "order created") | Searchable and countable | Every log line |
Child loggers | logger.child({ requestId }) | Context added once, appears everywhere | Per request or job |
Redaction | redact: ["*.password"] | Secrets never reach the log store | Always |
Levels | error, warn, info, debug | Alerts and noise stay separate | Every call |
Log at the handler | Global error handler logs once | No duplicate stack traces | Errors |
Try it
import pino from "pino";
export const logger = pino({
level: env.LOG_LEVEL,
redact: ["req.headers.authorization", "*.password", "*.cardNumber"],
base: { service: "orders-api" },
});
app.use((req, res, next) => {
const requestId = req.get("X-Request-Id") ?? crypto.randomUUID();
res.locals.log = logger.child({ requestId });
res.set("X-Request-Id", requestId);
next();
});
res.locals.log.info({ orderId: order.id, amount: order.amount }, "order created");
Message is a fixed phrase, data goes in fields. "order created" with orderId as a field lets you search and count; "order o_981 created" makes every line unique.
# a recorded session, replayed when you press Run node dist/main.js | head -2 {"level":30,"time":1759730400123,"service":"orders-api","requestId":"r_7f3","orderId":"o_981","amount":149900,"msg":"order created"} {"level":50,"time":1759730401567,"service":"orders-api","requestId":"r_8a1","err":{"type":"PaymentGatewayError","message":"Charge timed out for o_982"},"msg":"charge failed"}
Key terms
| Term | Simple meaning |
|---|---|
Structured log | A log line written as JSON fields |
Correlation id | One id shared by everything in a request |
Redaction | Hiding sensitive values in logs |
Log level | How serious a log line is |
Lint, format and git hooks
Let tools decide style. Prettier formats, ESLint with typescript-eslint catches bugs, and hooks run both before code leaves your laptop.
A spell checker and an autocorrect for code. You stop arguing about commas in review because the tools fix them before anyone sees the draft.
Prettier owns formatting; ESLint owns correctness, using the flat config with typescript-eslint's recommendedTypeChecked rules so it can see types (floating promises, unsafe any). Disable ESLint formatting rules with eslint-config-prettier. husky runs lint-staged on commit (only changed files, fast) and commitlint on the message. CI runs the same checks on everything. An .editorconfig keeps editors consistent.
Pick it forEvery repository, from day one.
- Format
- Prettier
- Correctness
- ESLint + typescript-eslint
- Hooks
- husky + lint-staged
- Commits
- commitlint
How it flows
requestresponsepush or streamcontrol
Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Prettier | prettier --write . | Zero formatting debates | All files |
Typed ESLint | recommendedTypeChecked + projectService | Catches async and any bugs | All TypeScript |
lint-staged | "*.ts": ["eslint --fix", "prettier --write"] | Fast: only changed files | pre-commit hook |
commitlint | @commitlint/config-conventional | Consistent history and changelogs | commit-msg hook |
.editorconfig | indent_style = space, end_of_line = lf | Same basics in every editor | Every repo |
Try it
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{ languageOptions: { parserOptions: { projectService: true } } },
{
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/no-explicit-any": "error",
},
},
prettier,
);
Typed linting finds real bugs. Rules like no-floating-promises need type information, which projectService provides with almost no setup.
# a recorded session, replayed when you press Run git commit -m "fix stuff" ✔ Preparing lint-staged... ✔ Running tasks for staged files... ✔ Applying modifications from tasks... ⧗ input: fix stuff ✖ subject may not be empty [subject-empty] ✖ type may not be empty [type-empty] ✖ found 2 problems, 0 warnings husky - commit-msg script failed (code 1)
Key terms
| Term | Simple meaning |
|---|---|
Linter | A tool that finds likely bugs and bad patterns |
Formatter | A tool that rewrites layout only |
Git hook | A script git runs at commit or push |
Flat config | ESLint's modern eslint.config.js format |
Comments, docs and commits
Code says what; comments say why. Public APIs get TSDoc, and commits follow Conventional Commits so history reads like a changelog.
Comments are the margin notes a good teacher leaves: not repeating the textbook, but explaining the tricky bit and why it matters. Commit messages are diary entries future you will thank you for.
Prefer renaming over commenting. Write comments for intent, constraints and surprises ("Gateway rejects amounts below 100 paise"), plus links to tickets for workarounds. Document exported functions with TSDoc (@param, @returns, @throws, @example) so editors show it on hover. Use Conventional Commits: type(scope): summary, with feat, fix, refactor, perf, test, docs, chore and BREAKING CHANGE footers. Branches follow type/ticket-short-description in kebab-case. Record big decisions in short ADRs.
Pick it forPublic functions, workarounds, every commit and every big decision.
- Comments
- Why, not what
- API docs
- TSDoc on exports
- Commits
- type(scope): summary
- Branches
- feat/ord-142-refunds
Avoid and prefer
// increment i
i++;
git commit -m "changes"Repeats the code; says nothing.// Gateway rejects amounts under 100 paise (ticket PAY-88)
git commit -m "fix(payments): round up sub-rupee refunds"Explains intent and history.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Why comments | // Retry twice: gateway drops first call after deploys | Captures knowledge the code cannot | Workarounds, constraints |
TSDoc | /** @param … @returns … @throws … */ | Hover docs and generated references | Exported functions and classes |
Conventional Commits | feat(orders): add refunds | Readable history, automatic changelogs and versions | Every commit |
Branch names | fix/pay-88-sub-rupee-refunds | Traceable to tickets | Every branch |
ADRs | docs/adr/0007-use-kafka-for-events.md | Decisions and trade offs survive | Architecture choices |
Try it
/**
* Refunds part or all of a captured payment.
*
* @param orderId - Order to refund; must be in the Paid state
* @param amountInPaise - Amount to refund; defaults to the full order total
* @returns The gateway refund id
* @throws {@link RefundNotAllowedError} when the order is not refundable
*
* @example
* const refundId = await refunds.refundOrder(orderId, 50_00);
*/
export async function refundOrder(orderId: OrderId, amountInPaise?: number): Promise<string> {
// The gateway rejects refunds under 100 paise, so round small refunds up (PAY-88)
const amount = Math.max(amountInPaise ?? (await orders.getTotal(orderId)), 100);
…
}
The summary line is for the log. Keep it under about 72 characters, imperative mood, lower case after the scope: fix(orders): handle empty cart.
# a recorded session, replayed when you press Run git log --oneline -5 a41c9e2 feat(refunds): support partial refunds 7d0b3f1 fix(payments): round up sub-rupee refunds c92e6aa refactor(orders): extract toOrderDto mapper 51f8b07 test(orders): cover cancel after shipment e3a1d44 chore(deps): bump pino to 9.5.0
Key terms
| Term | Simple meaning |
|---|---|
TSDoc | The standard comment format for TypeScript APIs |
Conventional Commits | A commit message format: type(scope): summary |
ADR | Architecture Decision Record, a short note on a choice |
BREAKING CHANGE | A footer that marks an incompatible change |
Tests that help, not hinder
Name tests as behaviour, keep them fast and isolated, and test through public functions rather than private details.
Tests are a safety net under a trapeze. A good net is close, catches you fast and tells you exactly where you slipped; a tangled one just gets in the way.
Name test files after the unit (order.service.test.ts) and cases as behaviour in plain words: it("rejects refunds for unpaid orders"). Structure each test as Arrange, Act, Assert. Prefer fakes and in memory repositories over deep mocks, and test public behaviour so refactors do not break tests. Keep unit tests in milliseconds; run integration tests against a real database in containers. Vitest and Jest share most APIs; NestJS's Test.createTestingModule wires providers.
Pick it forEvery service method with business rules, and every bug fix.
- Files
- *.test.ts beside the code
- Names
- it("does X when Y")
- Shape
- Arrange, Act, Assert
- Doubles
- Fakes over deep mocks
How it flows
requestresponsepush or streamcontrol
Avoid and prefer
it("test 1") · it("works") · it("refundOrder")Fails with a name that tells you nothing.it("rejects refunds for unpaid orders")The failure message is the bug report.Methods, usage, why and when
| Rule | Example | Why | When |
|---|---|---|---|
Behaviour names | it("rejects refunds for unpaid orders") | Failures explain themselves | Every test |
AAA structure | arrange, act, assert blocks | Easy to read and review | Every test |
Fakes | InMemoryOrderRepository | Simple, fast, refactor friendly | Repositories and gateways |
Test builders | anOrder({ status: Paid }) | Only relevant fields shown | Complex entities |
Integration tests | Testcontainers Postgres | Real SQL and migrations checked | Repositories, critical flows |
Try it
import { describe, it, expect, beforeEach } from "vitest";
describe("OrderService.refundOrder", () => {
let repo: InMemoryOrderRepository;
let gateway: FakePaymentGateway;
let service: OrderService;
beforeEach(() => {
repo = new InMemoryOrderRepository();
gateway = new FakePaymentGateway();
service = new OrderService(repo, gateway);
});
it("rejects refunds for unpaid orders", async () => {
repo.add(anOrder({ id: "o_1", status: OrderStatus.Pending })); // arrange
const refund = service.refundOrder("o_1" as OrderId); // act
await expect(refund).rejects.toBeInstanceOf(RefundNotAllowedError); // assert
expect(gateway.refunds).toHaveLength(0);
});
});
Test builders keep tests short. anOrder({ status }) fills sensible defaults, so each test shows only the fields that matter to it.
# a recorded session, replayed when you press Run npx vitest run src/orders ✓ src/orders/order.service.test.ts (6 tests) 14ms ✓ OrderService.refundOrder > rejects refunds for unpaid orders ✓ OrderService.refundOrder > refunds the full total by default Test Files 1 passed (1) Tests 6 passed (6) Duration 412ms
Key terms
| Term | Simple meaning |
|---|---|
Unit test | Checks one piece in isolation, in milliseconds |
Integration test | Checks pieces together with real dependencies |
Fake | A working, simple stand in such as an in memory store |
Mock | A stand in that records calls and returns set values |
Team checklist
Agree on these once, write them into your lint config and README, and stop debating them in code review.
| Area | Team rule | Enforced by |
|---|---|---|
Values and functions | camelCase, verbs for functions, is/has/can for booleans | naming-convention |
Types and classes | PascalCase, no I prefix, Dto/Response/Row suffixes | naming-convention |
Constants and env | SCREAMING_SNAKE_CASE, env read once and validated | Config schema at boot |
Files, folders, routes | kebab-case, feature folders, plural routes | Code review, file name lint |
Database | snake_case, mapped to camelCase in repositories | ORM naming strategy |
Compiler | strict + noUncheckedIndexedAccess | tsc --noEmit in CI |
Inputs | Parse at every boundary with schemas | Zod or ValidationPipe |
Errors | AppError family, cause on rethrow, one mapper | Exception filter, review |
Async | Await everything, timeouts on network calls | no-floating-promises |
Logs | JSON, requestId, redaction, log once | Logger config |
Style | Prettier formats, ESLint checks | lint-staged pre-commit |
History | Conventional Commits, kebab-case branches | commitlint |
Tests | Behaviour names, AAA, fakes, fast | vitest in CI |