Best Practices
Why Best Practices Matter
Following TypeScript best practices creates consistent, maintainable codebases that teams can navigate confidently. Conventions reduce cognitive load, prevent bugs, and make code reviews more effective by establishing shared expectations.
Core Benefits:
- Consistency: Code looks uniform across team members
- Readability: Clear naming and organization aids comprehension
- Maintainability: Standardized patterns simplify modifications
- Onboarding: New developers learn conventions quickly
- Quality: Best practices prevent common pitfalls
Problem: Without conventions, every developer creates their own style, leading to inconsistent, hard-to-maintain codebases.
Solution: Adopt industry-standard TypeScript conventions enforced by tooling (ESLint, Prettier) to ensure quality and consistency.
Naming Conventions
Consistent naming improves code readability and reduces cognitive load.
Variable and Function Names
Use camelCase for variables and functions, with descriptive names.
Pattern:
// ❌ BAD: Unclear abbreviations
let usr = { n: "Alice", a: 30 };
// => Abbreviated names save typing but hurt readability
// => Future developers waste time deciphering meaning
function calc(x: number, y: number): number {
// => Generic names provide no context
return x + y;
}
// ✅ GOOD: Descriptive camelCase names
const currentUser = { name: "Alice", age: 30 };
// => currentUser clearly indicates current authenticated user
// => name and age are self-documenting
function calculateTotal(subtotal: number, taxRate: number): number {
// => calculateTotal describes action and purpose
// => subtotal and taxRate clarify parameter meanings
return subtotal * (1 + taxRate);
// => Calculation self-evident from names
}Benefits:
- Code self-documents intent
- No need for excessive comments
- IDE autocomplete works better
Class and Interface Names
Use PascalCase for classes and interfaces.
Pattern:
// ❌ BAD: Lowercase class names
class userService {
// => Lowercase looks like variable
// => Confusing in large codebase
}
// ✅ GOOD: PascalCase classes
class UserService {
// => PascalCase clearly indicates class
// => Follows TypeScript/JavaScript convention
constructor(
private userRepository: UserRepository,
// => Dependency injected
// => camelCase for instance variables
) {}
async findById(id: string): Promise<User> {
// => camelCase method names
// => async clearly indicates async operation
return await this.userRepository.findById(id);
}
}Interface naming:
// ❌ BAD: "I" prefix (Hungarian notation)
interface IUser {
// => "I" prefix is C# convention, not TypeScript
// => TypeScript has structural typing, prefix unnecessary
name: string;
}
// ✅ GOOD: Clean interface names
interface User {
// => Clean name matches entity
// => TypeScript infers structure automatically
name: string;
email: string;
}
interface UserRepository {
// => Repository pattern interface
// => Describes role, not implementation
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}Benefits:
- Follows TypeScript/JavaScript conventions
- Clear distinction between types and values
- No Hungarian notation clutter
Constants and Enums
Use UPPER_SNAKE_CASE for constants, PascalCase for enums.
Pattern:
// Constants: UPPER_SNAKE_CASE
const MAX_RETRY_ATTEMPTS = 3;
// => Clearly indicates constant value
// => Convention signals immutability
const API_BASE_URL = "https://api.example.com";
// => Environment-specific constant
// => UPPER_SNAKE_CASE signals configuration
const DATABASE_CONNECTION_TIMEOUT_MS = 5000;
// => Timeout constant with unit suffix
// => _MS clearly indicates milliseconds
// Enums: PascalCase
enum UserRole {
// => Enum name in PascalCase
Admin = "ADMIN",
// => Enum values can be UPPER_SNAKE_CASE
User = "USER",
Guest = "GUEST",
}
enum OrderStatus {
// => Order lifecycle states
Pending = "PENDING",
Processing = "PROCESSING",
Shipped = "SHIPPED",
Delivered = "DELIVERED",
}Benefits:
- Constants easy to identify
- Enums provide type-safe alternatives to magic strings
File Naming
Use kebab-case for files, matching export names.
Pattern:
// ❌ BAD: Inconsistent file naming
UserService.ts // PascalCase
user_repository.ts // snake_case
userController.ts // camelCase
// ✅ GOOD: kebab-case files
user-service.ts // Exports UserService class
user-repository.ts // Exports UserRepository class
user-controller.ts // Exports UserController class
email-validator.ts // Exports EmailValidator classBenefits:
- Consistent across codebase
- Works on case-sensitive and case-insensitive filesystems
- Matches file content predictably
Code Organization
Organize code into logical modules and layers.
Project Structure
Use feature-based or layer-based organization.
Feature-based structure (recommended for large apps):
src/
├── users/ # User feature module
│ ├── user.entity.ts # User entity/model
│ ├── user.repository.ts # Data access
│ ├── user.service.ts # Business logic
│ ├── user.controller.ts # HTTP handlers
│ ├── user.dto.ts # Data transfer objects
│ └── user.validator.ts # Validation logic
├── orders/ # Order feature module
│ ├── order.entity.ts
│ ├── order.repository.ts
│ ├── order.service.ts
│ └── order.controller.ts
└── shared/ # Shared utilities
├── database/ # Database connection
├── validation/ # Shared validators
└── middleware/ # Express middlewareLayer-based structure (recommended for small apps):
src/
├── entities/ # Domain entities
│ ├── user.entity.ts
│ └── order.entity.ts
├── repositories/ # Data access layer
│ ├── user.repository.ts
│ └── order.repository.ts
├── services/ # Business logic layer
│ ├── user.service.ts
│ └── order.service.ts
├── controllers/ # Presentation layer
│ ├── user.controller.ts
│ └── order.controller.ts
└── utils/ # Shared utilitiesBenefits:
- Related code grouped together
- Easy to navigate and find files
- Scales with application size
Import Organization
Group and order imports consistently.
Pattern:
// 1. External dependencies (libraries)
import express from "express";
// => Express framework
import { z } from "zod";
// => Validation library
// 2. Internal modules (application code)
import { UserRepository } from "./user.repository";
// => Repository from same feature
import { EmailService } from "../shared/email.service";
// => Shared service from different feature
// 3. Types and interfaces
import type { User } from "./user.entity";
// => Type-only import (type keyword)
// => Avoids circular dependencies
import type { Request, Response } from "express";
// => Express types
// ✅ Benefits:
// - Grouped by category (external, internal, types)
// - Easy to identify dependencies
// - Consistent across files
ESLint enforcement:
{
"rules": {
"import/order": [
"error",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "type"],
"newlines-between": "always"
}
]
}
}Type Safety Best Practices
Maximize TypeScript’s type system benefits.
Avoid any Type
The any type disables type checking and should be avoided.
Anti-pattern:
// ❌ BAD: any disables type checking
function processData(data: any): any {
// => any accepts anything (no type safety)
// => any returns anything (no intellisense)
return data.value.toUpperCase();
// => Runtime error if data.value is not string
// => TypeScript cannot catch this
}
const result = processData({ value: 123 });
// => Compiles but crashes at runtime
// => result type is any (no type safety)
Best practice:
// ✅ GOOD: Use specific types
interface DataWithValue {
// => Define expected structure
value: string;
// => value must be string
}
function processData(data: DataWithValue): string {
// => Type-safe input and output
// => TypeScript verifies data.value is string
return data.value.toUpperCase();
// => Safe: TypeScript ensures value is string
}
const result = processData({ value: "hello" });
// => result type is string (type-safe)
// TypeScript catches errors at compile time:
// processData({ value: 123 }); // ❌ Error: Type 'number' is not assignable to 'string'
Use unknown Instead of any
When type is truly unknown, use unknown (requires type checking before use).
Pattern:
// ❌ BAD: any allows unsafe operations
function parseJSON(json: string): any {
// => Returns any (no type safety)
return JSON.parse(json);
// => JSON.parse returns any
}
const user = parseJSON('{"name":"Alice"}');
user.name.toUpperCase();
// => Compiles but may crash if structure different
user.age.toString();
// => Compiles but crashes if age doesn't exist
// ✅ GOOD: unknown requires type checking
function parseJSON(json: string): unknown {
// => Returns unknown (must check before use)
return JSON.parse(json);
}
const data = parseJSON('{"name":"Alice"}');
// => data type is unknown
// Type guard required before use
if (isUser(data)) {
// => Type guard narrows unknown to User
console.log(data.name.toUpperCase());
// => Safe: TypeScript knows data is User
}
function isUser(data: unknown): data is User {
// => Type guard function
// => data is User: Type predicate
return (
typeof data === "object" &&
// => Check is object
data !== null &&
// => Check not null
"name" in data &&
// => Check has name property
typeof (data as any).name === "string"
// => Check name is string
);
}Benefits:
- Safer than
any(requires type checking) - Forces explicit type validation
- Catches more bugs at compile time
Enable Strict Mode
Use strict: true in tsconfig.json for maximum type safety.
Configuration (tsconfig.json):
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}Benefits:
- Catches null/undefined errors
- Prevents implicit
any - Stricter function type checking
- Requires property initialization
Function Best Practices
Write clear, focused functions with single responsibilities.
Function Length
Keep functions short and focused (≤20 lines recommended).
Anti-pattern:
// ❌ BAD: Long function with multiple responsibilities
function processOrder(order: Order): void {
// => Too many responsibilities in one function
// => Hard to test, understand, and maintain
// Validate order (responsibility 1)
if (!order.items || order.items.length === 0) {
throw new Error("Order must have items");
}
// Calculate total (responsibility 2)
let total = 0;
for (const item of order.items) {
total += item.price * item.quantity;
}
// Apply discount (responsibility 3)
if (order.customer.isPremium) {
total *= 0.9;
}
// Calculate tax (responsibility 4)
const tax = total * 0.1;
total += tax;
// Update inventory (responsibility 5)
for (const item of order.items) {
inventory.reduce(item.productId, item.quantity);
}
// Create invoice (responsibility 6)
const invoice = new Invoice(order.id, total, tax);
invoiceRepository.save(invoice);
// Send email (responsibility 7)
emailService.sendOrderConfirmation(order.customer.email);
}Best practice:
// ✅ GOOD: Split into focused functions
function processOrder(order: Order): void {
// => Orchestrates order processing
// => Single responsibility: coordination
validateOrder(order);
// => Validates order structure
const total = calculateOrderTotal(order);
// => Calculates total with discounts and tax
updateInventory(order);
// => Reduces inventory
const invoice = createInvoice(order, total);
// => Creates invoice record
sendOrderConfirmation(order);
// => Sends confirmation email
}
function validateOrder(order: Order): void {
// => Single responsibility: validation
if (!order.items || order.items.length === 0) {
throw new Error("Order must have items");
}
}
function calculateOrderTotal(order: Order): number {
// => Single responsibility: calculation
let subtotal = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// => Calculate subtotal
if (order.customer.isPremium) {
subtotal *= 0.9;
// => Apply 10% premium discount
}
const tax = subtotal * 0.1;
// => Calculate 10% tax
return subtotal + tax;
// => Return total with tax
}Benefits:
- Each function has single purpose
- Easy to test in isolation
- Easy to understand
- Easy to reuse
Parameter Count
Limit function parameters (≤3 recommended, use objects for more).
Anti-pattern:
// ❌ BAD: Too many parameters
function createUser(
email: string,
password: string,
firstName: string,
lastName: string,
age: number,
city: string,
country: string,
phoneNumber: string,
): User {
// => 8 parameters: hard to remember order
// => Easy to mix up arguments
// => Difficult to add optional parameters
}
createUser("alice@example.com", "pass123", "Alice", "Smith", 30, "NYC", "USA", "555-1234");
// => Parameter order unclear
// => Easy to swap arguments
Best practice:
// ✅ GOOD: Use parameter object
interface CreateUserParams {
// => Parameter object interface
// => Named properties (self-documenting)
email: string;
password: string;
firstName: string;
lastName: string;
age?: number;
// => Optional parameter (? suffix)
city?: string;
country?: string;
phoneNumber?: string;
}
function createUser(params: CreateUserParams): User {
// => Single parameter object
// => Properties accessed by name
const { email, password, firstName, lastName } = params;
// => Destructure required properties
// ... create user logic
}
createUser({
// => Object with named properties
// => Order doesn't matter
email: "alice@example.com",
password: "pass123",
firstName: "Alice",
lastName: "Smith",
age: 30,
// => Optional parameters included as needed
});Benefits:
- Self-documenting parameter names
- Order doesn’t matter
- Easy to add optional parameters
- Type-safe with interface
Error Handling Best Practices
Handle errors explicitly and consistently.
Use Custom Error Classes
Create custom error classes for domain-specific errors.
Pattern:
// Base error class
class AppError extends Error {
// => Application base error
// => Extends built-in Error
constructor(
message: string,
public readonly statusCode: number,
// => HTTP status code
public readonly isOperational: boolean = true,
// => Operational error (expected) vs programmer error (bug)
) {
super(message);
// => Call Error constructor
this.name = this.constructor.name;
// => Set error name to class name
Error.captureStackTrace(this, this.constructor);
// => Capture stack trace
}
}
class ValidationError extends AppError {
// => Validation error (400 Bad Request)
constructor(message: string) {
super(message, 400);
// => 400 status code for validation errors
}
}
class NotFoundError extends AppError {
// => Resource not found (404)
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`, 404);
// => Descriptive message with resource and ID
}
}
class UnauthorizedError extends AppError {
// => Authentication error (401)
constructor(message: string = "Unauthorized") {
super(message, 401);
}
}
// Usage
function findUser(id: string): User {
const user = database.users.find((u) => u.id === id);
// => Query database
if (!user) {
throw new NotFoundError("User", id);
// => Throw domain-specific error
// => Caller knows exactly what happened
}
return user;
}Benefits:
- Clear error types
- HTTP status codes included
- Consistent error handling
Async/Await Best Practices
Use async/await for cleaner asynchronous code.
Always Handle Promise Rejections
Anti-pattern:
// ❌ BAD: Unhandled promise rejection
async function fetchUser(id: string): Promise<void> {
const user = await fetch(`/api/users/${id}`);
// => If fetch fails, promise rejection unhandled
// => Crashes application in production
console.log(user);
}Best practice:
// ✅ GOOD: Explicit error handling
async function fetchUser(id: string): Promise<void> {
try {
const response = await fetch(`/api/users/${id}`);
// => Attempt fetch
if (!response.ok) {
// => Check HTTP status
throw new Error(`HTTP ${response.status}`);
}
const user = await response.json();
// => Parse JSON
console.log(user);
} catch (error) {
// => Handle all errors
console.error("Failed to fetch user:", error);
// => Log error
throw error;
// => Re-throw for caller to handle
}
}Benefits:
- Explicit error handling
- Prevents unhandled rejections
- Clear error propagation
Production Framework: ESLint + Prettier
Enforce best practices automatically with tooling.
Installation:
npm install --save-dev eslint prettier eslint-config-prettier @typescript-eslint/parser @typescript-eslint/eslint-pluginESLint configuration (.eslintrc.json):
{
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "warn",
"max-lines-per-function": ["error", 50],
"max-params": ["error", 3],
"complexity": ["error", 10]
}
}Prettier configuration (.prettierrc):
{
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}Benefits:
- Automatic code formatting
- Enforced code quality rules
- Consistent style across team
Summary
TypeScript best practices ensure consistent, maintainable code. Use camelCase for variables/functions, PascalCase for classes/interfaces, kebab-case for files. Organize code by features or layers. Maximize type safety with strict mode, avoid any, use unknown when needed. Keep functions short, limit parameters, handle errors explicitly. Enforce practices with ESLint and Prettier.
Production checklist:
- ✅ Strict mode enabled (
tsconfig.json) - ✅ ESLint configured with TypeScript rules
- ✅ Prettier for automatic formatting
- ✅ No
anytypes (use specific types orunknown) - ✅ Custom error classes for domain errors
- ✅ Functions ≤50 lines, ≤3 parameters
- ✅ Consistent naming conventions
Related Resources
- Design Principles - SOLID, DRY, KISS, YAGNI
- Type Safety - Advanced type system usage
- Linting and Formatting - ESLint and Prettier setup