TypeScript Code Conventions
This document defines the TypeScript coding rules and preferences I follow. It is intended to instruct an AI assistant to generate code that matches these standards precisely.
1. Naming Conventions
Variables and Constants
- Use
camelCasefor variables andSCREAMING_SNAKE_CASEfor true module-level constants. - Prefer descriptive names over abbreviations. Never use single-letter names except in tight loops or generic type parameters.
// ✅ Do
const maxRetryCount = 3;
const MAX_RETRY_COUNT = 3;
const userProfileData = await fetchUser(id);
// ❌ Don't
const n = 3;
const d = await fetchUser(id);
const MaxRetryCount = 3;
Functions and Methods
- Use
camelCase. Name functions after what they do, not what they return. Prefer verb-first names.
// ✅ Do
function calculateTotalPrice(items: Item[]): number { ... }
async function fetchUserById(id: string): Promise<User> { ... }
// ❌ Don't
function total(items: Item[]): number { ... }
async function userData(id: string): Promise<User> { ... }
Classes and Constructors
- Use
PascalCasefor class names. Name classes after what they represent, not what they do.
// ✅ Do
class OrderProcessor { ... }
class UserRepository { ... }
// ❌ Don't
class processOrder { ... }
class handle_users { ... }
Interfaces and Types
- Use
PascalCasefor bothinterfaceandtypenames. - Do not prefix interfaces with
I(e.g., avoidIUser).
// ✅ Do
interface User { id: string; name: string; }
type UserId = string;
// ❌ Don't
interface IUser { id: string; name: string; }
type user_id = string;
Enums and Enum Members
- Use
PascalCasefor the enum name. UsePascalCasefor string enum members andSCREAMING_SNAKE_CASEfor numeric enum members.
// ✅ Do
enum Direction { North = "North", South = "South" }
enum StatusCode { OK = 200, NOT_FOUND = 404 }
// ❌ Don't
enum direction { north = "north" }
Generic Type Parameters
- Use single uppercase letters (
T,K,V) for simple generics. Use descriptivePascalCasenames when the parameter has a clear semantic role.
// ✅ Do
function identity<T>(value: T): T { return value; }
function getProperty<TObject, TKey extends keyof TObject>(obj: TObject, key: TKey) { ... }
// ❌ Don't
function identity<type>(value: type): type { return value; }
Files and Modules
- Use
kebab-casefor all file names:user-repository.ts,fetch-orders.ts. - Match the file name to the primary export it contains.
2. Type System
Type vs. Interface
- Use
interfacefor defining the shape of objects and class contracts. - Use
typefor unions, intersections, mapped types, conditional types, and primitives.
// ✅ Do — interface for object shape
interface User {
id: string;
name: string;
}
// ✅ Do — type for union
type Status = "active" | "inactive" | "pending";
// ❌ Don't — type alias for a plain object shape when interface is clearer
type User = { id: string; name: string };
Explicit vs. Inferred Types
- Let TypeScript infer types for local variables when the type is obvious from the initializer.
- Always annotate function parameters and return types explicitly.
// ✅ Do
const count = 0; // inferred as number
function add(a: number, b: number): number { return a + b; }
// ❌ Don't
function add(a, b) { return a + b; } // implicit any
const count: number = 0; // redundant annotation
Union and Intersection Types
- Use union types to express "one of these shapes". Use intersection types to compose types together.
- Prefer discriminated unions over optional properties for variant modeling.
// ✅ Do — discriminated union
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
// ❌ Don't — ambiguous optional properties
type Result<T> = { data?: T; error?: string; success: boolean };
Literal Types
- Use literal types to constrain values to a known set without reaching for an enum.
// ✅ Do
type Direction = "north" | "south" | "east" | "west";
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
// ❌ Don't
const direction: string = "north"; // loses all narrowing
Template Literal Types
- Use template literal types to express string patterns at the type level.
// ✅ Do
type EventName = `on${Capitalize<string>}`;
type CssVar = `--${string}`;
Utility Types
- Prefer built-in utility types over rewriting equivalent mapped types manually.
// ✅ Do
type PartialUser = Partial<User>;
type ReadonlyUser = Readonly<User>;
type UserPreview = Pick<User, "id" | "name">;
type UserWithoutId = Omit<User, "id">;
// ❌ Don't
type PartialUser = { id?: string; name?: string }; // duplicated, fragile
unknown vs. any vs. never
- Use
unknownwhen the type is not known at compile time. Force callers to narrow before use. - Never use
anyunless wrapping a third-party library with no types and you cannot improve it. - Use
neverfor exhaustive checks and unreachable code paths.
// ✅ Do
function parseJson(raw: string): unknown {
return JSON.parse(raw);
}
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${value}`);
}
// ❌ Don't
function parseJson(raw: string): any {
return JSON.parse(raw);
}
Type Assertions and Casting
- Avoid
astype assertions. When necessary, narrow with a type guard instead. - Never use double assertions (
x as unknown as T) to force a cast — fix the types instead.
// ✅ Do
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null && "id" in value;
}
// ❌ Don't
const user = response as User; // hides a potential mismatch
Type Guards and Narrowing
- Use
typeof,instanceof,in, and custom type predicates to narrow unions.
// ✅ Do
function processResult(result: string | number) {
if (typeof result === "string") {
return result.toUpperCase();
}
return result.toFixed(2);
}
Branded / Nominal Types
- Use branded types to prevent mixing semantically different primitives of the same underlying type.
// ✅ Do
type UserId = string & { readonly _brand: "UserId" };
type OrderId = string & { readonly _brand: "OrderId" };
function getUser(id: UserId): Promise<User> { ... }
// getUser(orderId) — compile error, catches bugs at the type level
3. Strict Mode
Required tsconfig Flags
Enable all of the following in every project:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
}
}
strictNullChecks Rules
strictNullChecksis included in"strict": true. Never disable it.- Always handle the
null | undefinedcase explicitly. Do not use non-null assertion (!) as a shortcut.
// ✅ Do
function getLength(str: string | null): number {
if (str === null) return 0;
return str.length;
}
// ❌ Don't
function getLength(str: string | null): number {
return str!.length; // runtime crash if str is null
}
noUncheckedIndexedAccess
- With this flag enabled, array and index-signature access returns
T | undefined. Always handle the undefined case.
// ✅ Do
const first = items[0];
if (first === undefined) return;
console.log(first.name);
// ❌ Don't
console.log(items[0].name); // unsafe without the check
exactOptionalPropertyTypes
- With this flag,
{ prop?: string }meanspropcan be absent but not explicitlyundefined. Assign accordingly.
// ✅ Do
const obj: { name?: string } = {}; // prop absent — ok
const obj2: { name?: string } = { name: "Alice" }; // prop present — ok
// ❌ Don't
const obj3: { name?: string } = { name: undefined }; // error with exactOptionalPropertyTypes
4. Interfaces and Classes
When to Use a Class vs. a Plain Object
- Use classes when you need encapsulation, methods, inheritance, or
instanceofchecks. - Prefer plain objects and functions for simple data containers or stateless logic.
// ✅ Do — class with behavior
class TokenBucket {
private tokens: number;
constructor(private readonly capacity: number) { this.tokens = capacity; }
consume(): boolean { ... }
}
// ✅ Do — plain object for data
const user: User = { id: "1", name: "Alice" };
Access Modifiers
- Use
privatefor implementation details,readonlyfor properties that must not change after construction. - Prefer
privateover#private fields unless you need hard runtime privacy.
// ✅ Do
class UserService {
constructor(
private readonly db: Database,
private readonly logger: Logger,
) {}
}
Abstract Classes
- Use abstract classes when multiple subclasses share implementation but differ in one or more methods.
// ✅ Do
abstract class BaseRepository<T> {
abstract findById(id: string): Promise<T | null>;
async findOrThrow(id: string): Promise<T> {
const result = await this.findById(id);
if (!result) throw new Error(`Not found: ${id}`);
return result;
}
}
Implementing Interfaces
- Explicitly annotate the
implementsclause so TypeScript enforces the contract.
// ✅ Do
interface Notifier {
send(to: string, message: string): Promise<void>;
}
class EmailNotifier implements Notifier {
async send(to: string, message: string): Promise<void> {
await sendEmail({ to, body: message });
}
}
Constructor Patterns
- Use constructor parameter properties (
private readonly x) to avoid boilerplate. - Avoid logic in constructors. Use a static factory method when construction can fail.
// ✅ Do
class Config {
private constructor(private readonly values: Record<string, string>) {}
static fromEnv(): Config {
const values = loadEnv(); // can throw
return new Config(values);
}
}
5. Generics
When to Use Generics
- Use generics when a function or class must work with multiple types while preserving type relationships. Do not use generics where a union or
unknownis simpler.
// ✅ Do — generic preserves the relationship
function first<T>(arr: T[]): T | undefined { return arr[0]; }
// ❌ Don't — unnecessary generic
function log<T>(value: T): void { console.log(value); }
// ✅ Better
function log(value: unknown): void { console.log(value); }
Constraints (extends)
- Use
extendsto constrain generics to a subset of types that have required properties.
// ✅ Do
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
Default Type Parameters
- Use default type parameters to make generics ergonomic without losing flexibility.
// ✅ Do
type ApiResponse<T = unknown> = { data: T; status: number };
Avoiding Over-Generalization
- Do not introduce generics just to make code look reusable. A concrete type that works is better than a generic that confuses.
// ❌ Don't
function wrap<T>(value: T): { value: T } { return { value }; }
// ✅ Do — if you only ever wrap strings
function wrapString(value: string): { value: string } { return { value }; }
6. Enums
const enum vs. Regular enum
- Avoid
const enumin library code or projects usingisolatedModules— it breaks across module boundaries. - Prefer string union types or
as constobjects over enums in most cases.
String Enums vs. Numeric Enums
- If you do use an enum, always use string enums. Numeric enums have reverse-mapping behavior that causes subtle bugs.
// ✅ Do
enum Status {
Active = "active",
Inactive = "inactive",
}
// ❌ Don't
enum Status { Active, Inactive } // numeric, fragile to reordering
Alternatives to Enums (as const + Union)
- Prefer
as constobjects with an inferred union type. They serialize correctly, work withisolatedModules, and are easier to iterate.
// ✅ Do
const Status = {
Active: "active",
Inactive: "inactive",
} as const;
type Status = typeof Status[keyof typeof Status]; // "active" | "inactive"
7. Functions
Return Type Annotations
- Always annotate return types on exported functions and class methods. Omit them for trivial private helpers where inference is unambiguous.
// ✅ Do
export function formatDate(date: Date): string { ... }
// ❌ Don't
export function formatDate(date: Date) { ... } // implicit return type leaks changes silently
Optional and Default Parameters
- Use default parameters instead of checking for
undefinedinside the body.
// ✅ Do
function createUser(name: string, role: string = "viewer"): User { ... }
// ❌ Don't
function createUser(name: string, role?: string): User {
const actualRole = role ?? "viewer";
...
}
Rest Parameters and Tuples
- Type rest parameters explicitly. Use labeled tuples for readability.
// ✅ Do
function merge<T>(...objects: T[]): T { return Object.assign({}, ...objects); }
type Range = [start: number, end: number];
Overloads
- Use function overloads to describe multiple valid call signatures. Keep the implementation signature unexported.
// ✅ Do
function parse(input: string): number;
function parse(input: number): string;
function parse(input: string | number): number | string {
return typeof input === "string" ? parseInt(input, 10) : String(input);
}
void vs. undefined Return Types
- Use
voidwhen the caller must not use the return value. Useundefinedwhen you explicitly returnundefinedand callers may check it.
// ✅ Do
function logError(error: Error): void { console.error(error); }
function findIndex(arr: number[], val: number): number | undefined { ... }
Arrow Functions vs. Function Declarations
- Use
functiondeclarations for top-level named functions (hoisted, easier to stack-trace). - Use arrow functions for callbacks, closures, and class property methods.
// ✅ Do
export function processOrder(order: Order): void { ... }
const sorted = items.sort((a, b) => a.price - b.price);
8. Modules and Imports
Import Ordering
Order imports in the following groups, separated by a blank line:
- Node built-ins
- External packages
- Internal absolute paths (aliases)
- Relative imports
// ✅ Do
import path from "node:path";
import { z } from "zod";
import { UserRepository } from "@/repositories/user-repository";
import { formatDate } from "./utils";
Barrel Files (index.ts)
- Use barrel files only at the boundary of a module/feature to define its public API.
- Do not create deep barrel chains that re-export everything — they slow down bundlers and hide dependencies.
// ✅ Do — src/users/index.ts
export { UserRepository } from "./user-repository";
export type { User } from "./user.types";
Re-exports
- Use named re-exports. Avoid wildcard re-exports (
export * from) as they obscure the public surface.
// ❌ Don't
export * from "./user-repository";
// ✅ Do
export { UserRepository } from "./user-repository";
Path Aliases
- Configure path aliases in
tsconfig.jsonand use them for cross-feature imports. Use relative paths only within the same feature folder.
{ "paths": { "@/*": ["./src/*"] } }
Circular Dependency Avoidance
- Never create circular imports. Structure modules so dependencies flow in one direction:
utils → domain → services → controllers.
9. Async and Error Handling
async/await vs. Promises
- Always use
async/await. Avoid.then()/.catch()chains except at the outermost call site.
// ✅ Do
async function getUser(id: string): Promise<User> {
const user = await db.findById(id);
return user;
}
// ❌ Don't
function getUser(id: string): Promise<User> {
return db.findById(id).then(user => user);
}
Typed Error Handling Patterns
- Wrap operations that can fail in a
try/catch. Type the caught value asunknownand narrow it.
// ✅ Do
try {
await riskyOperation();
} catch (err: unknown) {
if (err instanceof AppError) handleAppError(err);
else throw err;
}
// ❌ Don't
} catch (err) {
console.log(err.message); // err is any — unsafe
}
Result / Either Patterns
- For expected failure cases (validation, not-found), return a
Resulttype instead of throwing.
// ✅ Do
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function findUser(id: string): Promise<Result<User, "NOT_FOUND">> {
const user = await db.findById(id);
if (!user) return { ok: false, error: "NOT_FOUND" };
return { ok: true, value: user };
}
Error Class Conventions
- Extend
Errorfor custom errors. Always setthis.nameand callsuper(message).
// ✅ Do
class ValidationError extends Error {
readonly name = "ValidationError";
constructor(
message: string,
public readonly field: string,
) {
super(message);
}
}
Never Swallowing Errors Silently
- Never catch an error and do nothing. At minimum, re-throw or log.
// ❌ Don't
try { await op(); } catch { /* silent */ }
// ✅ Do
try { await op(); } catch (err: unknown) { logger.error(err); throw err; }
10. Null and Undefined
Nullish Coalescing (??) and Optional Chaining (?.)
- Use
??to provide defaults fornull | undefined. Do not use||— it also swallows0,"", andfalse. - Use
?.to safely traverse nullable chains.
// ✅ Do
const name = user?.profile?.name ?? "Anonymous";
const port = config.port ?? 3000;
// ❌ Don't
const port = config.port || 3000; // falsy on port 0
When to Allow null vs. undefined
- Use
undefinedfor optional properties and missing values (aligns with TypeScript's optional?syntax). - Use
nullonly when interfacing with APIs or databases that returnnullexplicitly. - Do not mix both in the same domain — pick one convention and stick to it.
Non-Null Assertion Operator (!) Rules
- Treat
!as a last resort. Use it only when you have proof the value cannot be null/undefined that the type system cannot express. Add a comment explaining why. - Never use
!to silence astrictNullCheckserror you have not actually reasoned about.
// ✅ Do — justified with comment
// Map is populated from DB rows; every userId is guaranteed to exist by the JOIN.
const user = userMap.get(userId) as User;
// ❌ Don't
const user = userMap.get(userId)!; // hides a potential undefined
11. Objects and Arrays
Immutability
- Mark object properties
readonlywhen they must not change after construction. - Use
as constfor static lookup objects and configuration. - Use
Readonly<T>andReadonlyArray<T>in function signatures to signal you do not mutate inputs.
// ✅ Do
const CONFIG = { timeout: 5000, retries: 3 } as const;
function process(items: ReadonlyArray<Item>): void { ... }
Destructuring Conventions
- Destructure at the top of a function for readability. Avoid destructuring deeply nested objects inline — create an intermediate variable.
// ✅ Do
function greet({ name, role }: User): string {
return `Hello ${name}, you are a ${role}`;
}
// ❌ Don't
function greet(user: User): string {
return `Hello ${user.name}, you are a ${user.role}`;
}
Spread Usage Rules
- Use spread for shallow clones and merging. Never use spread where mutation is required (it creates a copy).
- Do not use spread to pass large objects — be explicit about what you need.
// ✅ Do
const updated = { ...user, name: "Alice" };
// ❌ Don't
updateUser({ ...entireGiantObject }); // pass only what is needed
12. Comments and Documentation
JSDoc Usage Rules
- Document all exported functions, classes, and types that are not self-explanatory.
- Use
@param,@returns, and@throwstags. Keep prose short.
// ✅ Do
/**
* Fetches a user by ID. Returns null if not found.
* @throws {DatabaseError} if the query fails
*/
export async function findUser(id: UserId): Promise<User | null> { ... }
Inline Comment Standards
- Write comments that explain why, not what. If the code is clear, no comment is needed.
// ✅ Do
// Retry up to 3 times to handle transient network errors from the payment gateway.
for (let i = 0; i < 3; i++) { ... }
// ❌ Don't
// Loop 3 times
for (let i = 0; i < 3; i++) { ... }
@deprecated and @internal Tags
- Mark deprecated exports with
@deprecatedand a migration note. - Mark internal helpers with
@internalso tooling and humans know not to rely on them.
/** @deprecated Use `findUserById` instead. Will be removed in v3. */
export function getUser(id: string): Promise<User> { ... }
Avoiding Self-Evident Comments
- Do not comment code that reads like English already. Every comment must add information the code itself cannot convey.
// ❌ Don't
// Increment i by 1
i++;
// ❌ Don't
// Return the user
return user;
13. File and Project Structure
File Naming and Casing
- All files use
kebab-case:user-service.ts,create-order.handler.ts. - Test files mirror the source file with a
.test.tsor.spec.tssuffix:user-service.test.ts.
Folder Organization
Follow Hexagonal Architecture (Ports & Adapters). Business logic is completely isolated from infrastructure — the domain layer never imports from infrastructure.
src/
domain/ # Pure entities and business rules
user/
user.entity.ts
user.repository.interface.ts
application/ # Use cases — orchestration only
user/
create-user.use-case.ts
infrastructure/ # Real implementations (DB, HTTP, third-party)
database/
postgres-user.repository.ts
http/
express-user.controller.ts
Module Boundaries
domainhas zero external dependencies — no framework, no ORM, no HTTP library.applicationdepends only ondomaininterfaces, never oninfrastructuredirectly.infrastructureis the only layer allowed to import external packages and implement domain interfaces.
Test Structure
Separate tests by type, not co-located with source. Use a top-level tests/ folder organized by test category:
tests/
unit/
user/
create-user.use-case.test.ts
integration/
user/
postgres-user.repository.test.ts
e2e/
create-user.e2e.test.ts
14. Formatting and Style
Indentation and Spacing
- 2 spaces. No tabs.
Semicolons
- Always use semicolons.
Quotes
- Single quotes for strings. Template literals only when interpolating.
// ✅ Do
const name = 'Alice';
const greeting = `Hello, ${name}`;
Trailing Commas
- Use trailing commas on multi-line objects, arrays, function parameters, and generics (
"trailingComma": "all"in Prettier).
// ✅ Do
const user = {
id: '1',
name: 'Alice',
};
Line Length Limits
- 100 characters maximum per line.
Prettier / ESLint Configuration
- Prettier handles all formatting — no manual style debates. ESLint handles correctness rules.
- Do not disable ESLint rules inline without a comment explaining why.
// ✅ Do
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- wrapping untyped legacy SDK
const sdk: any = legacyInit();
15. Testing Conventions
Type-Safe Test Utilities
- Use typed factory functions with
@faker-js/fakerdefaults to create test fixtures. Avoid raw object literals spread across tests — they break silently when types change. - Override only the properties relevant to each test; let factories handle the rest.
// ✅ Do — factory with faker defaults
export const userExample = ({
id = faker.string.uuid(),
name = faker.person.fullName(),
role = faker.helpers.objectValue(UserRoleEnum),
} = {}): User => ({ id, name, role });
// Usage — override only what the test cares about
const admin = userExample({ role: 'admin' });
Mocking Typed Dependencies
- Use typed mocks. When mocking a class or interface, use a cast through
asonly after ensuring all required methods are present.
// ✅ Do
const mockRepo: jest.Mocked<UserRepository> = {
findById: jest.fn(),
save: jest.fn(),
};
Asserting Types in Tests
- Use
expectTypeOf(viaexpect-type) to assert type-level correctness in addition to runtime behavior.
// ✅ Do
import { expectTypeOf } from 'expect-type';
expectTypeOf(findUser('1')).toEqualTypeOf<Promise<User | null>>();