Table of Contents
1. Leverage TypeScript’s Type System Effectively
TypeScript’s greatest strength is its type system—use it to enforce clarity and catch errors early.
Avoid any at All Costs
The any type disables TypeScript’s checks, turning it into “JavaScript with extra steps.” It erodes type safety and makes refactoring risky.
Bad:
// `data` is untyped—no autocompletion, no error checks!
function fetchData(url: string): Promise<any> {
return fetch(url).then(res => res.json());
}
// Accidental typo won’t be caught!
const user = await fetchData("/api/user");
console.log(user.namme); // No error at compile time 😱
Good:
Define a strict type for the response:
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(url: string): Promise<User> {
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to fetch user");
return res.json() as Promise<User>; // Use `as` only if you trust the API!
}
const user = await fetchUser("/api/user");
console.log(user.namme); // Type error: Property 'namme' does not exist on type 'User' ✅
If you truly don’t know the type (e.g., parsing unknown JSON), use unknown instead of any, then narrow the type with checks:
function parseUnknownData(data: unknown): User | null {
if (typeof data === "object" && data !== null && "id" in data) {
return data as User; // Safe after narrowing
}
return null;
}
Prefer Interfaces for Public Contracts
Use interface for defining public APIs (e.g., component props, library types) because they support declaration merging. Use type for unions, intersections, or complex type operations.
Example:
// Interface: Good for props or public contracts
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary";
}
// Type: Good for unions/intersections
type Status = "loading" | "success" | "error";
type ApiResponse<T> = { data: T } | { error: string };
Use Utility Types to Reduce Redundancy
TypeScript’s built-in utility types (e.g., Partial, Readonly, Pick) simplify common type transformations, avoiding repetitive code.
Example: Partial for optional updates
interface User {
id: number;
name: string;
email: string;
}
// Instead of rewriting a "partial" User:
// interface UpdateUserInput { id?: number; name?: string; email?: string }
type UpdateUserInput = Partial<User>; // { id?: number; name?: string; email?: string }
Example: Pick to select specific properties
// Extract only `name` and `email` from User
type UserSummary = Pick<User, "name" | "email">; // { name: string; email: string }
Narrow Types with Type Guards
Type guards let you “narrow” a type from a broader union to a specific subtype, making code safer.
Example: Checking for Array
function isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
function logItems(data: unknown) {
if (isArray(data)) {
data.forEach(item => console.log(item)); // data is now unknown[] ✅
} else {
console.log("Not an array");
}
}
2. Naming Conventions and Code Readability
Clean code is readable code. Consistent naming makes your intent clear at a glance.
Consistent Naming for Types and Interfaces
- PascalCase for types/interfaces:
User,ButtonProps,ApiResponse. - Avoid prefixing interfaces with
I(e.g.,IUser)—modern TypeScript conventions discourage this (it’s redundant and unnecessary).
Bad:
interface iUser { ... } // Lowercase 'i'
type userProfile = { ... }; // camelCase
Good:
interface User { ... }
type UserProfile = { ... };
Descriptive Variable and Function Names
Names should answer: What does this do? or What is this? Avoid single letters (unless they’re universally understood, like i in loops).
Bad:
const d = new Date(); // What is 'd'?
function h(a: number, b: number) { return a + b; } // What is 'h'? What do 'a' and 'b' represent?
Good:
const currentDate = new Date();
function sumPrices(basePrice: number, tax: number): number { return basePrice + tax; }
Boolean Naming Patterns
Booleans should start with is, has, should, or can to make their purpose obvious.
Bad:
const active = true; // Is this a user? A feature?
const loaded = false; // Loaded what?
Good:
const isUserActive = true;
const hasDataLoaded = false;
const shouldShowModal = true;
3. Code Organization and Modularity
Well-organized code is easier to navigate and scale.
Single Responsibility Principle (SRP)
Each file, function, or component should do one thing. If a file exceeds 200–300 lines, split it.
Bad:
A UserPage.tsx file that:
- Fetches user data
- Renders the UI
- Handles form validation
- Manages state for filters
Good:
Split into:
UserPage.tsx(renders UI)useUserData.ts(custom hook for fetching data)UserFormValidator.ts(validation logic)userFilters.ts(state management for filters)
Feature-Based File Structure
Organize code by feature (e.g., auth, dashboard) instead of type (e.g., components, hooks). This reduces cross-folder navigation.
Example Structure:
src/
features/
auth/
components/LoginForm.tsx
hooks/useLogin.ts
types/Auth.types.ts
api/login.ts
dashboard/
components/DashboardStats.tsx
hooks/useDashboardData.ts
shared/
components/Button.tsx
utils/dateFormatter.ts
Barrel Files for Clean Imports
Use barrel files (index.ts) to re-export modules, simplifying imports.
Example:
In features/auth/index.ts:
export { LoginForm } from "./components/LoginForm";
export { useLogin } from "./hooks/useLogin";
export type { AuthUser } from "./types/Auth.types";
Now import from the feature root:
import { LoginForm, useLogin, type AuthUser } from "@/features/auth";
4. Writing Clean Functions and Components
Keep Functions Short and Focused
Aim for functions under 10–15 lines. If a function does multiple things, split it into smaller functions.
Bad:
function processOrder(order: Order) {
// Step 1: Validate order
if (!order.items.length) throw new Error("Empty order");
// Step 2: Calculate total
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0);
const tax = subtotal * 0.08;
const total = subtotal + tax;
// Step 3: Save to DB
db.saveOrder({ ...order, total });
// Step 4: Send email
emailService.sendConfirmation(order.email);
}
Good:
function validateOrder(order: Order): void {
if (!order.items.length) throw new Error("Empty order");
}
function calculateTotal(order: Order): number {
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0);
return subtotal * 1.08; // Subtotal + tax
}
async function processOrder(order: Order): Promise<void> {
validateOrder(order);
const total = calculateTotal(order);
await db.saveOrder({ ...order, total });
await emailService.sendConfirmation(order.email);
}
Pure Functions for Predictability
Pure functions (no side effects, same input → same output) are easier to test and debug.
Bad:
let total = 0;
function addToTotal(amount: number): void {
total += amount; // Side effect: modifies external state
}
Good:
function addToTotal(currentTotal: number, amount: number): number {
return currentTotal + amount; // Pure: no side effects
}
// Usage:
let total = 0;
total = addToTotal(total, 10); // Explicit state update
Limit Props in Components
Too many props make components hard to use. Use object destructuring or combine related props into a single object.
Bad:
function UserCard(name: string, email: string, age: number, isVerified: boolean, avatarUrl: string) {
// ...
}
Good:
interface UserCardProps {
user: {
name: string;
email: string;
age: number;
isVerified: boolean;
};
avatarUrl: string;
}
function UserCard({ user, avatarUrl }: UserCardProps) {
// ...
}
5. Effective Error Handling
TypeScript helps make error handling explicit and type-safe.
Define Custom Error Types
Create typed errors to distinguish between failure cases (e.g., network errors vs. validation errors).
Example:
class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = "NetworkError";
}
}
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = "ValidationError";
}
}
// Usage:
async function fetchData() {
try {
const res = await fetch("/api/data");
if (!res.ok) throw new NetworkError("Failed to fetch");
const data = await res.json();
if (!data.id) throw new ValidationError("id", "Missing required field");
return data;
} catch (error) {
if (error instanceof NetworkError) {
console.error("Network issue:", error.message);
} else if (error instanceof ValidationError) {
console.error(`Invalid ${error.field}:`, error.message);
}
}
}
Use never for Exhaustive Checks
The never type ensures all cases in a union are handled, preventing missing logic.
Example:
type Status = "loading" | "success" | "error";
function handleStatus(status: Status): string {
switch (status) {
case "loading": return "Fetching data...";
case "success": return "Data loaded!";
case "error": return "Oops, failed!";
default:
// If a new Status is added (e.g., "pending"), TypeScript throws an error here.
const exhaustiveCheck: never = status;
throw new Error(`Unhandled status: ${exhaustiveCheck}`);
}
}
Avoid Silent Failures
Never ignore errors with empty catch blocks. Log them or propagate them to be handled upstream.
Bad:
try {
riskyOperation();
} catch (error) {
// Silent failure—no one knows something broke!
}
Good:
try {
riskyOperation();
} catch (error) {
console.error("Operation failed:", error);
throw error; // Re-throw to let upstream code handle it
}
6. Avoiding Common Pitfalls
Don’t Abuse Type Assertions (as)
Type assertions (as) tell TypeScript, “I know better than you.” Overusing them bypasses type safety.
Bad:
const user = {} as User; // `user` is empty but typed as User—runtime errors!
user.name.toUpperCase(); // Crash: Cannot read property 'toUpperCase' of undefined
Good:
Use type guards or initialize with required properties:
const user: Partial<User> = {}; // Explicitly partial
if (user.name) {
user.name.toUpperCase(); // Safe after check
}
Beware of Overusing Optional Chaining (?.)
Optional chaining (obj?.prop) is useful, but overusing it hides bugs (e.g., accidental undefined values).
Bad:
const userName = data?.user?.profile?.name; // What if `data` is undefined? Why is the chain so long?
Good:
Validate early and limit chaining:
if (!data || !data.user || !data.user.profile) {
throw new Error("Invalid user data structure");
}
const userName = data.user.profile.name; // No need for chaining now
Avoid Circular Dependencies
Circular imports (e.g., A.ts imports B.ts, which imports A.ts) cause runtime errors and make code hard to follow.
Fix:
Extract shared types into a separate file (e.g., types/shared.ts) or use dependency injection.
7. Tooling for Clean Code
Automate enforcement of clean code rules with tools.
ESLint + TypeScript
Use @typescript-eslint to catch TypeScript-specific issues (e.g., no-explicit-any, consistent-type-definitions).
Example .eslintrc.js:
module.exports = {
parser: "@typescript-eslint/parser",
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended" // For React projects
],
rules: {
"@typescript-eslint/no-explicit-any": "error", // Ban `any`
"@typescript-eslint/consistent-type-definitions": ["error", "interface"], // Prefer interfaces
"no-console": ["warn", { allow: ["warn", "error"] }] // Disallow console.log in production
}
};
Prettier for Formatting
Prettier auto-formats code (e.g., line length, indentation) to avoid bikeshedding.
Example .prettierrc:
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5"
}
Husky and Lint-Staged
Run ESLint/Prettier on staged files before commits to ensure code quality.
Setup:
npm install husky lint-staged --save-dev
npx husky install
npx husky add .husky/pre-commit "npx lint-staged"
Example package.json:
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
}
Conclusion
Clean TypeScript code isn’t about perfection—it’s about consistency, clarity, and leveraging TypeScript’s features to make your codebase robust and maintainable. By following these practices—avoiding any, keeping functions focused, organizing by feature, and using tooling—you’ll write code that’s easier to debug, refactor, and scale.
Remember: Clean code is a habit. Start small (e.g., fixing any types or renaming variables) and iterate. Your future self (and your team) will thank you!