DX Unpacked 0/20

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.

TypeScriptNode.jsNestJS

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.

Name it wellNameReaderName → Reader, meaning arrives

Names and cases that tell readers what a thing is.

Type it tightCodeTypesCode ⇄ Types, on every save

Types and validation that catch mistakes early.

Fail gracefullytrycatchtry ⇄ catch, with context

Errors and logs that make the cause obvious.

Automate the restCommitHooksLintTestCommit → hooks → lint, test

Tools and habits that keep the team consistent.

01

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.

readableconsistentfast feedbackAutomate the rest
In one linePrinciples
In simple words

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.

Under the hood

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

Feedback from fastest to slowest
You
Editor
Git hooks
CI
save file
type and lint errors in ms
git commit
format + lint staged files in s
git push
tests + build in min
the earlier a mistake is caught, the cheaper it is

requestresponsepush or streamcontrol

Avoid and prefer

✗ Avoidconst d = await svc.proc(u, true, 3);What is d? What does true mean? Why 3?
✓ Preferconst invoice = await billing.createInvoice({ userId, sendEmail: true, dueInDays: 3 });Every name and argument explains itself.

Methods, usage, why and when

RuleExampleWhyWhen
Optimise for readingDescriptive names over commentsCode is read many times, written onceAlways
Automate conventionsESLint, Prettier, hooksReviews focus on logic, not styleFrom the first commit
Fail earlystrict types, validated configBugs surface at build, not in productionEvery boundary
Small, focused unitsOne job per function and fileEasier to test and reuseWhen a unit needs "and" to describe it
Document the whyREADME, ADRs, short commentsDecisions survive people leavingNon obvious choices

Try it

check.shBASH
# 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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
DXDeveloper experience: how easy it is to work on the code
Shift leftCatch problems earlier in the workflow
ConventionA rule everyone follows so nobody has to think about it
Cognitive loadHow much a reader must hold in their head
02

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.

camelCasePascalCasesnake_casekebab-caseName it well
In one lineEight cases
In simple words

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.

Under the hood

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

Same three words, eight cases. Highlighted marks show where each case splits the words.
Caseuser account idRuleUse it for
camelCaseuserAccountIdFirst word lower, each next word starts upperVariables, functions, methods, object keys, JSON fields
PascalCaseUserAccountIdEvery word starts upper, no separatorsClasses, interfaces, types, enums, decorators, React components
snake_caseuser_account_idAll lower, words joined by underscoresDatabase tables and columns, Python code
SCREAMING_SNAKE_CASEUSER_ACCOUNT_IDAll upper, words joined by underscoresEnvironment variables, true constants
kebab-caseuser-account-idAll lower, words joined by hyphensFile names, URL paths, CLI flags, package names
dot.caseuser.account.idAll lower, words joined by dotsEvent names, config keys, metric names
Train-CaseUser-Account-IdEach word capitalised, joined by hyphensHTTP headers such as Content-Type, X-Request-Id
flatcaseuseraccountidAll lower, no separatorsAvoid: hard to read; only where a system forces it

Avoid and prefer

✗ AvoidgetHTTPResponse · userID · XMLParserShouting acronyms hides word boundaries.
✓ PrefergetHttpResponse · userId · XmlParserAcronyms behave like normal words.

Methods, usage, why and when

RuleExampleWhyWhen
Separator by capitalsuserAccountId, UserAccountIdValid identifiers in TypeScriptCode: values and types
Separator by underscoreuser_account_id, MAX_RETRIESCase insensitive systems keep the words apartSQL, env vars, constants
Separator by hyphenuser-account-id.ts, /user-accountsSafe in file systems and URLsFiles, routes, packages
Acronyms as wordsHttpClient, parseJson, userIdWord boundaries stay visibleEvery case
Convert at boundariesORM naming strategy, mappersOne case inside the appDB rows and third party payloads

Try it

cases.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
CaseHow a multi word name is written
SeparatorWhat marks the gap between words
AcronymShort form like HTTP or ID
Boundary conversionChanging case once where data enters or leaves
03

Which case goes where

A lookup table for every place a name appears in a TypeScript backend, so nobody has to guess.

lookuplint enforcedone rule eachName it well
In one lineReference
In simple words

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.

Under the hood

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

✗ Avoidconst User_Count = 0; class order_service {}Mixed cases make names look like typos.
✓ Preferconst userCount = 0; class OrderService {}Values camelCase, classes PascalCase.

Methods, usage, why and when

RuleExampleWhyWhen
Variables, params, functionsorderTotal, calculateTax()TypeScript and JavaScript normAll runtime values
Classes, types, interfaces, enumsOrderService, CreateOrderDto, OrderStatusSignals a type or constructorAnything you can use as a type
Module constantsMAX_RETRIES, DEFAULT_PAGE_SIZEShows it never changesFixed values known at build time
Files and foldersorder.service.ts, payment-gateway/Case safe across macOS, Linux and WindowsEvery file
Env varsDATABASE_URL, JWT_SECRETShell and OS conventionProcess configuration
SQLorder_items.created_atPostgres folds unquoted names to lower caseTables, columns, indexes

Try it

eslint.config.mjsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

PlaceCaseExample
Variable, function, methodcamelCasecreateOrder
Class, interface, type, enumPascalCaseOrderStatus
Enum memberPascalCaseOrderStatus.Paid
ConstantSCREAMING_SNAKE_CASEMAX_RETRIES
Env variableSCREAMING_SNAKE_CASEDATABASE_URL
File, folder, URL pathkebab-case/order-items
DB table, columnsnake_casecreated_at
JSON fieldcamelCasecreatedAt
HTTP headerTrain-CaseX-Request-Id
Event, queue, metricdot.caseorder.created
04

Naming variables and booleans

A good variable name says what it holds, in what unit, and for booleans, what question it answers.

nounsis / has / canunitsName it well
In one lineVariables
In simple words

Labels on storage boxes. "Stuff" helps nobody; "Winter jackets, kids, 2025" tells you what is inside without opening it.

Under the hood

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

✗ Avoidconst flag = true; const t = 5000; const data = await get();Which flag? 5000 what? What data?
✓ Preferconst isEmailVerified = true; const timeoutMs = 5000; const pendingOrders = await getPendingOrders();Readable without opening the function.

Methods, usage, why and when

RuleExampleWhyWhen
Boolean prefixisPaid, hasAccess, canRetry, shouldNotifyReads like a yes or no questionEvery boolean
Plural collectionsusers, orderIdsShows it holds manyArrays and sets
Maps named by keyuserById, priceBySkuSays how to look things upMaps and records
Units in namesdelayMs, sizeBytes, amountInPaisePrevents unit mix upsNumbers with units
Scope sized namesi in a 3 line loop, activeSubscriptionCount at module levelShort where obvious, long where far awayAlways

Try it

variables.tsTS
// 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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
PredicateA boolean that answers a question
Noise wordA word that adds nothing: data, info, item
ScopeWhere a name is visible
Numeric separator5_000 is 5000, easier to read
05

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.

verb + nounget vs fetchhandlersName it well
In one lineFunctions
In simple words

A to do list item. "Invoice" is vague; "send the invoice to the customer" tells you exactly what will happen when you tick it.

Under the hood

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

✗ Avoidfunction user(e) { … } · processData() · doStuff()No verb, or a verb that says nothing.
✓ PreferfindUserByEmail(email) · chargeCustomer() · toInvoiceDto(order)The verb promises the behaviour.

Methods, usage, why and when

RuleExampleWhyWhen
getgetById, getConfigReturns or throws; never undefinedThe thing must exist
findfindByEmail, findActiveReturns T | undefined or []Lookups that may miss
create, update, deletecreateOrder, updateAddressClear write intentMutations
to, build, formattoDto, buildWhereClause, formatInrPure, no side effectsTransforms
is, has, canisRefundable(order)Returns booleanPredicates and guards
handle, onhandlePaymentCaptured, onShutdownReacts to an eventListeners, consumers

Try it

order.service.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Side effectAnything a function changes outside itself
Pure functionSame input, same output, no side effects
PredicateA function that returns true or false
GuardA check that narrows a type or stops early
06

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.

UPPER_CASEas constunionsName it well
In one lineConstants
In simple words

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.

Under the hood

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

✗ Avoidif (retries > 3) … if (status === 'paid') …Magic values repeated, typos compile.
✓ Preferif (retries > MAX_RETRIES) … if (status === OrderStatus.Paid) …Named once, checked by the compiler.

Methods, usage, why and when

RuleExampleWhyWhen
Module constantexport const MAX_RETRIES = 3Named, searchable, one place to changeLimits, defaults, timeouts
as const object{ Paid: "paid" } as constPlain values, derived union typeStatus, roles, kinds
String uniontype Role = "admin" | "member"Lightest option, no runtime objectSmall sets used only in types
TypeScript enumenum OrderStatus { Paid = "paid" }Familiar, works with NestJS SwaggerCodebases already using enums; string values only
Exhaustive switchconst x: never = valueCompiler finds missing casesEvery switch over a union

Try it

order-status.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Magic numberAn unexplained literal in code
as constMakes values read only and keeps their exact literal types
Union typeA value that is one of a fixed set
Exhaustive checkProof every option was handled
07

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.

PascalCaseno I prefixunionsType it tight
In one lineTypes
In simple words

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.

Under the hood

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

✗ Avoidinterface IPayment { card?: string; vpa?: string; type: string }Every field optional, any combination allowed.
✓ Prefertype PaymentMethod = | { kind: "card"; last4: string } | { kind: "upi"; vpa: string }Only valid combinations exist.

Methods, usage, why and when

RuleExampleWhyWhen
interfaceinterface UserRepository { … }Extendable object contractsShapes classes implement
type aliastype Id = string; type Result = A | BUnions and computed typesEverything else
Discriminated union{ kind: "card" } | { kind: "upi" }Impossible states cannot be builtVariants and lifecycle states
Utility typesPick, Omit, Partial, Readonly, RecordDerive types instead of copyingDTOs and views
GenericsRepository<TEntity, TId>Reusable and still typedShared helpers and base classes

Try it

payment.types.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
DTOData transfer object: the shape that crosses a boundary
DiscriminantThe field that tells union members apart
NarrowingTypeScript learning a more exact type after a check
GenericA type with a placeholder filled in later
08

Typed arguments, params and returns

Type every boundary explicitly, prefer one options object over long argument lists, and make invalid calls fail to compile.

explicit returnsoptions objectreadonlyType it tight
In one lineSignatures
In simple words

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.

Under the hood

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

✗ AvoidsendEmail(to, true, false, 3)What do true, false and 3 mean?
✓ PrefersendEmail({ to, isTransactional: true, trackOpens: false, retryCount: 3 })Self describing and order free.

Methods, usage, why and when

RuleExampleWhyWhen
Explicit return typefunction total(): MoneyContract cannot drift by accidentExported functions
Options objectfn({ a, b, c })Named, optional, order independentMore than 2 params or any boolean
Defaults in destructuring{ limit = 20 }: OptionsDefaults visible in the signatureOptional settings
readonly inputsreadonly items: readonly Item[]Signals no mutationArrays and objects passed in
Branded idstype OrderId = Brand<string, "OrderId">Stops mixing ids of the same base typeEntity ids, money, units
unknown at edgesparse(body: unknown)Forces validationRequest bodies, JSON, catch values

Try it

notifications.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
ParameterThe name in the function definition
ArgumentThe value you pass when calling
Branded typeA primitive tagged so similar values cannot mix
any vs unknownany turns checks off; unknown requires a check
09

Files, folders and modules

Group code by feature, name files by role in kebab-case, and keep imports short and predictable.

kebab-casefeature foldersrole suffixName it well
In one lineStructure
In simple words

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.

Under the hood

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

✗ Avoidsrc/controllers/OrderController.ts · src/services/orderSvc.tsScattered by layer, mixed cases.
✓ Prefersrc/orders/order.controller.ts · src/orders/order.service.tsEverything about orders in one place.

Methods, usage, why and when

RuleExampleWhyWhen
Role suffix*.controller.ts, *.service.ts, *.dto.tsFind files by what they doEvery file
Feature folderssrc/orders/, src/payments/Changes stay in one folderServices past a few modules
Colocated testsorder.service.test.ts beside the codeEasy to find and keep in syncUnit tests
Path aliasesimport { AppError } from "@common/errors"No ../../../ chainsDeep trees
Public barrels onlyorders/index.ts exports the module APIClear boundaries, no cyclesModule entry points

Try it

src/TREE
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Barrel fileAn index.ts that re-exports other files
Path aliasA short import prefix mapped in tsconfig
ColocationKeeping related files side by side
Circular importTwo files importing each other, often breaking at runtime
10

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.

strictnoUncheckedIndexedAccessNodeNextType it tight
In one lineCompiler
In simple words

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.

Under the hood

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

✗ Avoidconst first = users[0]; first.emailCompiles, then crashes on an empty list.
✓ Preferconst first = users[0]; if (!first) return; first.emailThe compiler made you handle empty.

Methods, usage, why and when

RuleExampleWhyWhen
strict"strict": trueNull checks, no implicit any, unknown in catchAlways
noUncheckedIndexedAccessarr[0] is T | undefinedEmpty arrays and missing keys are handledAlways for new code
exactOptionalPropertyTypesa?: string rejects undefinedOptional means absent, not undefinedAPIs where the difference matters
noImplicitOverrideoverride keyword requiredCatches renamed base methodsClass hierarchies
verbatimModuleSyntaximport type { X }Predictable emitted importsESM projects

Try it

tsconfig.jsonJSON
{
  "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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
strictNullChecksnull and undefined must be handled explicitly
noImplicitAnyUntyped values are errors, not silently any
NodeNextModule rules that match how Node.js loads files
RatchetTightening one rule at a time, never loosening
11

Validate at the boundaries

Types disappear at runtime. Parse everything that enters the system once, at the edge, and trust the typed result inside.

Zodparse, don't validateDTOsType it tight
In one lineRuntime safety
In simple words

A security check at the airport entrance. Everyone is checked once at the door, so nobody needs to be checked again at every gate.

Under the hood

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

Reject bad data at the door, pass typed data inside
Client
Controller
Schema
Service
POST /orders { amount: "abc" }
safeParse(body)
issue at amount
400 VALIDATION_FAILED
POST /orders { amount: 1499 }
safeParse(body)
CreateOrderInput
typed input, no more checks

requestresponsepush or streamcontrol

Methods, usage, why and when

RuleExampleWhyWhen
Zod schemaz.object({ … })Runtime check plus inferred typeExpress, Fastify, workers, scripts
NestJS DTOclass-validator + ValidationPipe({ whitelist: true })Framework native, Swagger friendlyNestJS controllers
safeParseschema.safeParse(input)No throw, explicit branchRequest handlers
Coercionz.coerce.number() for query stringsQuery values arrive as stringsPagination, filters
Validate outbound tooparse third party API responsesTheir contract can changePayment gateways, partner APIs

Try it

create-order.schema.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
BoundaryWhere outside data enters your code
Parse, don't validateTurn unknown data into a typed value once
SchemaA description of valid data that code can check
WhitelistDrop properties the schema does not mention
12

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.

unknowncausefinallyFail gracefully
In one lineErrors
In simple words

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 the hood

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

Add context where you catch, handle once at the top
Service
Gateway client
Payment API
Global handler
charge(order)
POST /charges
ETIMEDOUT
catch: wrap with cause
throw PaymentGatewayError { cause }
not recoverable here: propagate
log once, map to 502

requestresponsepush or streamcontrol

Avoid and prefer

✗ Avoidtry { await charge() } catch (e) { console.log(e) }Swallowed: the caller thinks payment worked.
✓ Prefercatch (err) { throw new PaymentGatewayError("Charge failed", { cause: err }) }Context added, original error kept.

Methods, usage, why and when

RuleExampleWhyWhen
Catch to recoverretry, fallback, default valueThe error is fully handledTransient, expected failures
Catch to translatethrow new DomainError(…, { cause })Callers see meaningful errorsLibrary and network errors
Let it propagateno try/catchOne handler decides the responseWhen you cannot do anything useful
finallyrelease(), clearTimeout()Cleanup runs on every pathResources and locks
Narrow unknownerr instanceof AppErrorSafe property accessEvery catch block

Try it

payment.client.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
SwallowingCatching an error and doing nothing useful with it
causeThe original error attached to a new one
PropagateLet the error travel up to a caller
Global handlerOne place that turns errors into responses
13

Custom errors and HTTP mapping

A small family of typed errors, with codes, mapped to HTTP responses in exactly one place.

AppErrorerror codesRFC 9457Fail gracefully
In one lineError design
In simple words

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.

Under the hood

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

Services throw meaning, one filter decides the HTTP answer
Client
Controller
Service
Error filter
GET /orders/o_404
getById(o_404)
throw OrderNotFoundError
code ORDER_NOT_FOUND → 404
404 · application/problem+json

requestresponsepush or streamcontrol

Methods, usage, why and when

RuleExampleWhyWhen
Base AppErrorabstract class with code + statusConsistent shape for every errorEvery service
Domain subclassesNotFound, Conflict, ForbiddenReadable throws, typed catchesExpected business failures
Single mapper@Catch(AppError) filter or middlewareHTTP rules live in one placeEvery API
Problem Detailsapplication/problem+jsonStandard error bodyPublic APIs
Result type{ ok: true, value } | { ok: false, error }Failures visible in the signatureExpected outcomes in hot paths

Try it

app-error.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Error codeA stable string that identifies the error kind
Exception filterNestJS class that turns errors into responses
Problem DetailsRFC 9457 standard JSON for HTTP errors
Result typeReturn success or failure instead of throwing
14

Async and promises

Await every promise, run independent work in parallel, and put a timeout on anything that waits on the network.

awaitPromise.allAbortSignalFail gracefully
In one lineConcurrency
In simple words

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).

Under the hood

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

Independent calls in parallel, each with a limit
Handler
Postgres
Redis
Pricing API
user
cart
prices · timeout 2 s
40 ms
5 ms
180 ms
Promise.all: total ≈ 180 ms instead of 225 ms

requestresponsepush or streamcontrol

Avoid and prefer

✗ Avoidfor (const id of ids) { await sendEmail(id) } · sendEmail(user)Slow serial loop; a floating promise.
✓ Preferawait Promise.all(ids.map(sendEmail)) · await sendEmail(user)Parallel, and every promise awaited.

Methods, usage, why and when

RuleExampleWhyWhen
Promise.allawait Promise.all([a(), b()])Parallel; fails fast on the first errorIndependent work that must all succeed
Promise.allSettledawait Promise.allSettled(list)Every result, success or failureBatches where partial success is fine
TimeoutsAbortSignal.timeout(2_000)No request waits foreverEvery outbound network call
Limited concurrencyp-limit, or chunks of 10Protects databases and rate limitsLarge batches
void for fire and forgetvoid track(event)Intent is explicitNon critical side work that handles its own errors

Try it

checkout.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Floating promiseA promise nobody awaits or handles
Unhandled rejectionAn async error nobody caught
Concurrency limitMaximum tasks running at once
AbortSignalA standard way to cancel async work
15

API routes and database naming

Outside TypeScript, follow each system's own conventions: kebab-case plural routes, camelCase JSON, snake_case SQL.

/kebab-casecamelCase JSONsnake_case SQLName it well
In one lineExternal names
In simple words

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.

Under the hood

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

✗ AvoidPOST /createOrder · GET /Orders/getById?ID=42 · column createdAtVerbs in URLs, mixed cases, quoted SQL names.
✓ PreferPOST /v1/orders · GET /v1/orders/42 · column created_atEach system's own convention.

Methods, usage, why and when

RuleExampleWhyWhen
Plural resource paths/v1/orders, /v1/orders/42Predictable REST URLsEvery endpoint
kebab-case segments/payment-methodsURLs are case insensitive in practice and readableMulti word resources
camelCase JSON{ "createdAt": … }Matches TypeScript objects, no mappingRequest and response bodies
snake_case SQLcreated_at, customer_idNo quoting needed in PostgresTables, columns, indexes
Timestamped migrations20261006090000_add_refunds.sqlOrdered, conflict freeEvery schema change

Try it

order.repository.tsTS
// 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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
ResourceThe thing a URL names, such as an order
Path parameterA value inside the URL, like :orderId
Foreign keyA column pointing to another table, like user_id
MigrationA versioned script that changes the schema
16

Configuration and environment

Read environment variables in one module, validate them at startup, and fail fast with a clear message when something is missing.

SCREAMING_SNAKEvalidatedfail fastAutomate the rest
In one lineConfig
In simple words

A pilot's preflight checklist. Every gauge is checked before take off, so you never discover the missing fuel at 30,000 feet.

Under the hood

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

Fail at boot, not at the first request
Process start
config/env.ts
process.env
App
import env
read raw strings
DATABASE_URL missing
schema fails: print and exit(1)
on success: typed, frozen config

requestresponsepush or streamcontrol

Avoid and prefer

✗ Avoidconst port = process.env.PORT || 3000; // in 9 filesUntyped strings, scattered, silently defaulted.
✓ Preferimport { env } from "@config/env"; app.listen(env.PORT);One validated, typed source.

Methods, usage, why and when

RuleExampleWhyWhen
Single config moduleimport { env } from "@config/env"Typed, testable, one sourceEvery service
Schema validationz.object({ … }).safeParse(process.env)Clear errors at bootStartup
Coercionz.coerce.number() for PORTEnv values are always stringsNumbers and booleans
Prefixed namesPAYMENT_API_KEY, PAYMENT_TIMEOUT_MSGrouped and self describingMany related settings
.env.exampleKEY=placeholderNew joiners know what to setCommitted to the repo

Try it

config/env.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Environment variableA setting passed to the process from outside
Fail fastStop immediately when something essential is wrong
CoercionConverting a string into the right type
Secret managerA secure store for passwords and keys
17

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.

pinorequestIdlevelsFail gracefully
In one lineObservability
In simple words

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.

Under the hood

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

One id ties every line of a request together
Client
Middleware
Service
Log store
GET /orders · X-Request-Id: r_7f3
child logger with requestId
handle request
{level:info, requestId:r_7f3, msg:"order loaded"}
{level:info, requestId:r_7f3, status:200, ms:42}
200 · X-Request-Id: r_7f3

requestresponsepush or streamcontrol

Avoid and prefer

✗ Avoidconsole.log("error!!", err); console.log("user", user)Unsearchable text, may leak personal data.
✓ Preferlogger.error({ err, orderId, requestId }, "charge failed")Structured, searchable, safe.

Methods, usage, why and when

RuleExampleWhyWhen
Structured logslogger.info({ orderId }, "order created")Searchable and countableEvery log line
Child loggerslogger.child({ requestId })Context added once, appears everywherePer request or job
Redactionredact: ["*.password"]Secrets never reach the log storeAlways
Levelserror, warn, info, debugAlerts and noise stay separateEvery call
Log at the handlerGlobal error handler logs onceNo duplicate stack tracesErrors

Try it

logger.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Structured logA log line written as JSON fields
Correlation idOne id shared by everything in a request
RedactionHiding sensitive values in logs
Log levelHow serious a log line is
18

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.

ESLintPrettierlint-stagedAutomate the rest
In one lineTooling
In simple words

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.

Under the hood

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

Style fixed automatically, messages checked
You
husky
lint-staged
commitlint
git commit -m "fix stuff"
pre-commit
eslint --fix, prettier --write ✔
commit-msg
✖ type may not be empty
fix the message: fix(orders): handle empty cart

requestresponsepush or streamcontrol

Methods, usage, why and when

RuleExampleWhyWhen
Prettierprettier --write .Zero formatting debatesAll files
Typed ESLintrecommendedTypeChecked + projectServiceCatches async and any bugsAll TypeScript
lint-staged"*.ts": ["eslint --fix", "prettier --write"]Fast: only changed filespre-commit hook
commitlint@commitlint/config-conventionalConsistent history and changelogscommit-msg hook
.editorconfigindent_style = space, end_of_line = lfSame basics in every editorEvery repo

Try it

eslint.config.mjsJS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
LinterA tool that finds likely bugs and bad patterns
FormatterA tool that rewrites layout only
Git hookA script git runs at commit or push
Flat configESLint's modern eslint.config.js format
19

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.

TSDocwhy commentsConventional CommitsAutomate the rest
In one lineCommunication
In simple words

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.

Under the hood

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

✗ Avoid// increment i i++; git commit -m "changes"Repeats the code; says nothing.
✓ Prefer// 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

RuleExampleWhyWhen
Why comments// Retry twice: gateway drops first call after deploysCaptures knowledge the code cannotWorkarounds, constraints
TSDoc/** @param … @returns … @throws … */Hover docs and generated referencesExported functions and classes
Conventional Commitsfeat(orders): add refundsReadable history, automatic changelogs and versionsEvery commit
Branch namesfix/pay-88-sub-rupee-refundsTraceable to ticketsEvery branch
ADRsdocs/adr/0007-use-kafka-for-events.mdDecisions and trade offs surviveArchitecture choices

Try it

refund.service.tsTS
/**
 * 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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
TSDocThe standard comment format for TypeScript APIs
Conventional CommitsA commit message format: type(scope): summary
ADRArchitecture Decision Record, a short note on a choice
BREAKING CHANGEA footer that marks an incompatible change
20

Tests that help, not hinder

Name tests as behaviour, keep them fast and isolated, and test through public functions rather than private details.

VitestAAAdescribe / itAutomate the rest
In one lineTesting
In simple words

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.

Under the hood

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

Arrange, act, assert with simple fakes
Test
OrderService
Fake repo
Fake gateway
arrange: seed unpaid order
act: refundOrder(o_1)
getById
status pending
throws RefundNotAllowedError
assert: error thrown, gateway never called

requestresponsepush or streamcontrol

Avoid and prefer

✗ Avoidit("test 1") · it("works") · it("refundOrder")Fails with a name that tells you nothing.
✓ Preferit("rejects refunds for unpaid orders")The failure message is the bug report.

Methods, usage, why and when

RuleExampleWhyWhen
Behaviour namesit("rejects refunds for unpaid orders")Failures explain themselvesEvery test
AAA structurearrange, act, assert blocksEasy to read and reviewEvery test
FakesInMemoryOrderRepositorySimple, fast, refactor friendlyRepositories and gateways
Test buildersanOrder({ status: Paid })Only relevant fields shownComplex entities
Integration testsTestcontainers PostgresReal SQL and migrations checkedRepositories, critical flows

Try it

order.service.test.tsTS
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.

shree@devbox: ~Ubuntu 24.04
# 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

TermSimple meaning
Unit testChecks one piece in isolation, in milliseconds
Integration testChecks pieces together with real dependencies
FakeA working, simple stand in such as an in memory store
MockA 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.

AreaTeam ruleEnforced by
Values and functionscamelCase, verbs for functions, is/has/can for booleansnaming-convention
Types and classesPascalCase, no I prefix, Dto/Response/Row suffixesnaming-convention
Constants and envSCREAMING_SNAKE_CASE, env read once and validatedConfig schema at boot
Files, folders, routeskebab-case, feature folders, plural routesCode review, file name lint
Databasesnake_case, mapped to camelCase in repositoriesORM naming strategy
Compilerstrict + noUncheckedIndexedAccesstsc --noEmit in CI
InputsParse at every boundary with schemasZod or ValidationPipe
ErrorsAppError family, cause on rethrow, one mapperException filter, review
AsyncAwait everything, timeouts on network callsno-floating-promises
LogsJSON, requestId, redaction, log onceLogger config
StylePrettier formats, ESLint checkslint-staged pre-commit
HistoryConventional Commits, kebab-case branchescommitlint
TestsBehaviour names, AAA, fakes, fastvitest in CI