# Performance Optimization Standards for TypeScript
This document outlines coding standards and best practices specifically for performance optimization in TypeScript projects. Adhering to these guidelines will improve the speed, responsiveness, efficient use of resources, and overall user experience of your applications.
## 1. Architectural Considerations for Performance
### 1.1. Code Splitting
**Standard:** Implement code splitting to reduce the initial load time of your application.
**Why:** Loading only the necessary code on initial page load significantly improves the user experience.
**Do This:**
* Utilize dynamic imports ("import()") to load modules on demand.
* Configure your bundler (Webpack, Parcel, Rollup) to create separate chunks for different parts of your application.
**Don't Do This:**
* Load the entire application code in a single bundle.
* Use "require()" statements (CommonJS) in modern TypeScript projects where ES Modules are supported.
**Example:**
"""typescript
// Before: Loading everything upfront
import { featureA } from './featureA';
import { featureB } from './featureB';
// After: Code splitting with dynamic imports
async function loadFeatureA() {
const { featureA } = await import('./featureA');
featureA.init();
}
async function loadFeatureB() {
const { featureB } = await import('./featureB');
featureB.init();
}
// Use loadFeatureA or loadFeatureB based on user interaction or route
"""
**Bundler Configuration (Webpack example):**
"""javascript
// webpack.config.js
module.exports = {
entry: './src/index.ts',
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
optimization: {
splitChunks: {
chunks: 'all', // Split all chunks of code
},
},
};
"""
### 1.2. Lazy Loading Modules
**Standard:** Employ lazy loading for non-critical modules or components.
**Why:** Reduce the amount of code that needs to be parsed and compiled on initial load.
**Do This:**
* Load components or modules only when they are needed.
* Utilize Intersection Observer API to load components when they become visible in the viewport.
**Don't Do This:**
* Load modules that are not immediately required for the current user interaction.
**Example (Intersection Observer Lazy Loading):**
"""typescript
function lazyLoadComponent(element: HTMLElement, importPath: string) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting) {
const { default: Component } = await import(importPath);
const componentInstance = new Component(); // Instantiate the component.
element.appendChild(componentInstance.render()); // Append to the DOM (adjust according to your framework).
observer.unobserve(element);
}
});
});
observer.observe(element);
}
// Usage:
const lazyComponentElement = document.getElementById('lazy-component');
if (lazyComponentElement) {
lazyLoadComponent(lazyComponentElement, './MyHeavyComponent');
}
"""
### 1.3. Server-Side Rendering (SSR) or Static Site Generation (SSG)
**Standard:** Consider using SSR or SSG for content-heavy, SEO-sensitive, or performance-critical applications.
**Why:** Reduces the time to first paint (TTFP) and improves SEO by providing crawlers with pre-rendered content.
**Do This:**
* Evaluate the trade-offs between SSR, SSG, and client-side rendering (CSR) based on your application's needs. Next.js (React), Nuxt.js (Vue), and Angular Universal are popular frameworks.
* Implement appropriate caching strategies for SSR.
**Don't Do This:**
* Default to CSR when SSR or SSG could provide significant performance benefits.
**Example (Next.js):**
"""typescript
// pages/index.tsx (Next.js example)
import React from 'react';
interface Props {
data: {
title: string;
description: string;
};
}
const HomePage: React.FC = ({ data }) => {
return (
{data.title}
<p>{data.description}</p>
);
};
export async function getServerSideProps() {
// Fetch data from an API, database, or file system.
const data = {
title: 'My Awesome Website',
description: 'Welcome to my extremely performant website!',
};
return {
props: {
data,
},
};
}
export default HomePage;
"""
### 1.4. Choosing appropriate data structures.
**Standard:** Select the best data structure for the job.
**Why:** Using appropriate data structures will reduce the complexity and improve the execution speed of algorithms.
**Do This:**
* Use "Map" when you need to associate keys with values, *especially when the keys are not strings or numbers*.
* Use "Set" when you need to store a collection of unique values.
* Use "Record" for simple key/value storage with string keys.
* Use arrays for storing ordered lists when random access is important.
* Use typed arrays ("Uint8Array", "Int32Array", etc.) for handling binary data.
**Don't Do This:**
* Use objects as maps when keys might not be strings.
* Use arrays to check for uniqueness when "Set" is more appropriate.
* Use nested loops when a "Map" or "Set" could optimize the search.
**Example:**
"""typescript
// Use Map when keys might be objects
const userMap = new Map(); //User is an object
const userA = { id: 1 };
const userB = { id: 2 };
userMap.set(userA, "User A");
userMap.set(userB, "User B");
console.log(userMap.get(userA)); //User A
"""
## 2. Code-Level Optimizations
### 2.1. Immutability
**Standard:** Favor immutable data structures and operations where possible.
**Why:** Immutability simplifies reasoning about code, prevents unintended side effects, and can enable performance optimizations like memoization.
**Do This:**
* Use "const" for variables that should not be reassigned.
* Use immutable data structures provided by libraries like Immutable.js or Immer.
* Avoid mutating arrays directly; use methods like "map", "filter", and "reduce" that return new arrays.
**Don't Do This:**
* Mutate objects or arrays directly without considering the consequences.
**Example (Immer):**
"""typescript
import { produce } from 'immer';
interface State {
items: { id: number; value: string }[];
}
const baseState: State = {
items: [
{ id: 1, value: 'initial' },
{ id: 2, value: 'initial' },
],
};
const nextState = produce(baseState, (draft) => {
draft.items[1].value = 'updated';
});
console.log(baseState.items[1].value); // "initial"
console.log(nextState.items[1].value); // "updated"
console.log(baseState === nextState); // false (new object created)
"""
### 2.2. Memoization
**Standard:** Utilize memoization to cache the results of expensive function calls.
**Why:** Avoid redundant computations by storing and reusing results.
**Do This:**
* Use memoization techniques for pure functions (functions that always return the same output for the same input and have no side effects).
* Libraries like "lodash.memoize" or custom implementations.
**Don't Do This:**
* Memoize functions with side effects or functions that rely on external state.
**Example (Memoization with a Custom Function):**
"""typescript
function memoize any>(func: T): T {
const cache = new Map();
return function (...args: Parameters): ReturnType {
const key = JSON.stringify(args); // Consider a more robust key generation.
if (cache.has(key)) {
return cache.get(key);
}
const result = func(...args);
cache.set(key, result);
return result;
} as T;
}
function expensiveCalculation(n: number): number {
console.log('Calculating...'); // This will only be logged the first time with a given input.
let result = 0;
for (let i = 0; i < n; i++) {
result += i;
}
return result;
}
const memoizedCalculation = memoize(expensiveCalculation);
console.log(memoizedCalculation(1000)); // Calculates and logs
console.log(memoizedCalculation(1000)); // Returns cached result
console.log(memoizedCalculation(2000)); // Calculates and logs, different input
"""
### 2.3. Loop Optimization
**Standard:** Optimize loops to minimize unnecessary iterations and computations.
**Why:** Loops are often performance bottlenecks, and even small improvements can have a significant impact.
**Do This:**
* Cache array lengths outside the loop if the length is used multiple times.
* Avoid unnecessary operations inside the loop.
* Use "for...of" for iterating over arrays when the index is not needed.
* Consider using "Array.forEach", "Array.map", "Array.filter", and "Array.reduce" with caution, understanding their performance implications compared to traditional loops. For very large datasets, traditional loops might be faster.
**Don't Do This:**
* Perform DOM manipulations inside loops; batch updates instead.
* Use "for...in" for iterating over arrays; it's designed for object properties.
**Example:**
"""typescript
// Before: Inefficient loop
const myArray = new Array(1000).fill(0);
for (let i = 0; i < myArray.length; i++) {
console.log(myArray.length); // Accessing length in each iteration
}
// After: Optimized loop
const myArray2 = new Array(1000).fill(0);
const arrayLength = myArray2.length; // Cache the length
for (let i = 0; i < arrayLength; i++) {
// Perform operations using 'i'
}
//Using "for...of" when the index is not needed.
const myArray3 = ['a','b','c'];
for(const item of myArray3){
console.log(item);
}
"""
### 2.4. Object Creation
**Standard:** Re-use objects when possible to reduce garbage collection overhead.
**Why:** Frequent object creation and destruction can lead to memory fragmentation and increased garbage collection times
**Do This:**
* Use object pools for frequently created and destroyed objects.
* Avoid creating temporary objects within frequently called functions.
**Don't Do This:**
* Create new objects unnecessarily within loops or frequently called functions.
**Example (Object Pool):**
"""typescript
class ReusableObject {
// Object Properties
public id:number;
constructor(id:number){
this.id = id;
}
reset() {
// Reset object properties to a default state
this.id = 0;
}
}
class ObjectPool {
private pool: ReusableObject[] = [];
private maxSize: number;
private nextId:number = 1;
constructor(maxSize: number) {
this.maxSize = maxSize;
this.initialize();
}
private initialize(): void {
for (let i = 0; i < this.maxSize; i++) {
this.pool.push(new ReusableObject(this.getNextId()));
}
}
public getNextId():number{
return this.nextId++;
}
public acquire(): ReusableObject | undefined {
if (this.pool.length > 0) {
return this.pool.pop();
}
return undefined; // Or consider expanding the pool if necessary
}
public release(obj: ReusableObject): void {
obj.reset(); // Reset the object before returning it to the pool
this.pool.push(obj);
}
}
// Usage
const pool = new ObjectPool(10); // Create a pool of 10 reusable objects
// Acquire an object from the pool
const obj1 = pool.acquire();
if (obj1) {
console.log("Acquired object with ID:", obj1.id);
// Use the object
// When done, release the object back into the pool
pool.release(obj1);
}
const obj2 = pool.acquire();
if (obj2) {
console.log("Acquired object with ID:", obj2.id);
pool.release(obj2);
}
"""
### 2.5. String Concatenation
**Standard:** Use template literals or array joining for efficient string concatenation.
**Why:** Repeatedly concatenating strings with the "+" operator can be inefficient, especially in loops, because strings in JavaScript are immutable and each concatenation creates a new string object.
**Do This:**
* Use template literals ("" "...${variable}..." "") for simple concatenations.
* Use "Array.join('')" for building strings from multiple parts, especially within loops.
**Don't Do This:**
* Use the "+" operator for repeated string concatenations in performance-critical sections.
**Example:**
"""typescript
// Before: Inefficient string concatenation
let myString = '';
for (let i = 0; i < 1000; i++) {
myString += i.toString(); // Creates a new string in each iteration
}
// After: Efficient string concatenation with array joining
const stringParts: string[] = [];
for (let i = 0; i < 1000; i++) {
stringParts.push(i.toString());
}
const myString2 = stringParts.join('');
//After: Efficient string concatenation with template literal
const name = "John";
const greeting = "Hello, ${name}!"; // More readable and often more efficient for simple cases.
"""
### 2.6. Type Annotations
**Standard:** Employ type annotations strategically to help the compiler optimize code.
**Why:** Explicit types give the compiler more information, allowing it to perform better optimizations
**Do This:**
* Use type annotations, especially for complex data structures and function signatures.
* Use specific types instead of "any" when possible.
**Don't Do This:**
* Overuse "any" and let the compiler infer types when explicit annotations can provide more information.
**Example:**
"""typescript
// Before: Implicit type
const data = [1, 2, 3]; // Inferred type: number[]
// After: Explicit type annotation
const data2: number[] = [1, 2, 3]; // More explicit, can aid compiler optimizations
// Before: any is used and can't be optimized
function processData(input: any): any {
return input * 2;
}
// After: Type annotation allows optimization.
function AddFive(input: number): number {
return input + 5;
}
"""
## 3. Tooling and Libraries
### 3.1. Profiling Tools
**Standard:** Regularly profile your application to identify performance bottlenecks.
**Why:** Profiling provides insights into where your application is spending its time.
**Do This:**
* Use browser developer tools (Chrome DevTools, Firefox Developer Tools) to profile JavaScript execution, memory usage, and rendering performance.
* Use Node.js profiling tools (e.g., "node --inspect") for server-side applications.
* Consider using dedicated profiling libraries.
**Don't Do This:**
* Rely solely on guesswork; always profile to confirm suspected performance issues.
### 3.2. Bundler Optimization
**Standard:** Configure your bundler to optimize the output bundle size and loading performance.
**Why:** Bundlers can significantly impact the performance of your application based on how they are configured.
**Do This:**
* Enable minification and tree shaking in your bundler configuration.
* Use code splitting to reduce the initial load time.
* Configure asset caching with appropriate cache headers.
* Use modern module formats (ESM) to enable more efficient tree shaking.
**Don't Do This:**
* Use development builds in production.
* Include unnecessary dependencies in the final bundle.
## 4. Security Considerations that impact Performance
### 4.1. Input Validations and Sanitization
**Standard:** Always validate and sanitize user inputs to prevent security vulnerabilities, and do this while minimizing performance impact.
**Why:** While primarily a security practice, neglecting input sanitization can lead to performance-heavy attacks like regular expression denial-of-service (ReDoS).
**Do This:**
* Use efficient and well-tested libraries for input validation and sanitization.
* Implement server-side validation even if client-side validation is present.
* Use parameterized queries or prepared statements to prevent SQL injection.
* Use allow-lists instead of block-lists for input validation where possible.
**Don't Do This:**
* Trust user inputs without validation.
* Use overly complex regular expressions that could be exploited for ReDoS attacks. Doing so makes your application slower and open to security flaws.
**Example:**
"""typescript
// Using a validation library to sanitize input
import * as validator from 'validator';
function sanitizeInput(input: string): string {
if (typeof(input) !== 'string'){
return '';
}
const trimmedInput = input.trim(); // Trim whitespace
if (trimmedInput.length === 0){
return '';
}
//Use a library to escape the characters or words from the input string
const sanitizedInput = validator.escape(trimmedInput);
return sanitizedInput;
}
const userInput = 'Hello!';
const sanitizedValue = sanitizeInput(userInput); //The string is now safe to display
interface MyFormValues{
description:string
}
function submitForm(values:MyFormValues){
//You can now submit this form to ths server
console.log(values.description)
}
submitForm({description:sanitizedValue})
"""
### 4.2. Rate Limiting.
**Standard:** Implement rate limiting to protect against brute-force attacks and denial-of-service (DoS) attacks.
**Why:** Limiting the number of requests from a single user or IP address within a specific time frame prevents malicious actors from overwhelming your server.
**Do This:**
* Use a rate-limiting middleware in your API endpoints.
* Configure reasonable limits based on the expected usage patterns.
* Return appropriate HTTP status codes (e.g., 429 Too Many Requests) when limits are exceeded.
* Consider using a distributed rate-limiting system for scaled applications.
**Don't Do This:**
* Expose APIs without rate limiting.
* Set overly restrictive limits that impact legitimate users.
### 4.3. Secure Coding Practices
**Standard:** Adhere to secure coding principles to minimize vulnerabilities that could be exploited for performance-degrading attacks.
**Why:** Well-written code has fewer bugs, fewer vulnerabilities, and can be optimized.
**Do This:**
* Follow the principle of least privilege.
* Handle errors gracefully and avoid exposing sensitive information in error messages.
* Keep dependencies up to date to patch security vulnerabilities.
* Use a linter and static analysis tools to catch potential security issues.
**Don't Do This:**
* Hardcode credentials or API keys in the code.
* Ignore warnings from linters or static analysis tools.
By adhering to these standards, you can create robust TypeScript applications that are not only performant but also secure and maintainable. Regular code reviews and profiling will help ensure that your code continues to meet these standards as your application evolves.
danielsogl
Created Mar 6, 2025
# Core Architecture Standards for TypeScript
This document outlines coding standards specifically for the core architecture of TypeScript projects. It focuses on fundamental architectural patterns, project structure, and organization principles essential for large, maintainable, and scalable TypeScript applications.
## 1. Architectural Patterns
### 1.1 Microservices Architecture
**Standard:** When building large-scale applications, consider a microservices architecture.
* **Do This:** Decompose the application into independent, deployable services. Each service should own a specific business domain.
* **Don't Do This:** Create a monolithic application that combines disparate functionalities into a single codebase, leading to tightly coupled components and scalability bottlenecks.
**Why:** Microservices enable independent scaling, deployment, and technology choices for different parts of the application, significantly boosting agility and resilience.
**Code Example:** Illustrating a simple microservice interface
"""typescript
// microservice-interface.ts
export interface UserService {
getUser(id: string): Promise<{ id: string; name: string; email: string }>;
createUser(user: { name: string; email: string }): Promise<string>;
}
export class UserServiceClient implements UserService {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async getUser(id: string): Promise<{ id: string; name: string; email: string }> {
const response = await fetch("${this.baseUrl}/users/${id}");
return response.json();
}
async createUser(user: { name: string; email: string }): Promise<string> {
const response = await fetch("${this.baseUrl}/users", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
const newUser = await response.json();
return newUser.id;
}
}
"""
### 1.2 Layered Architecture
**Standard:** Organize code into distinct layers (presentation, application, domain, infrastructure).
* **Do This:** Separate concerns by grouping related functionality into loosely coupled layers.
* **Don't Do This:** Mix presentation logic with business rules or database access code.
**Why:** Layered architecture improves testability, maintainability, and reusability by isolating dependencies and responsibilities.
**Code Example:** Layered approach to data handling with repositories
"""typescript
// domain/user.ts
export interface User {
id: string;
name: string;
email: string;
}
// infrastructure/user-repository.ts
import { User } from '../domain/user';
export interface UserRepository {
getUserById(id: string): Promise<User | null>;
saveUser(user: User): Promise<void>;
}
export class InMemoryUserRepository implements UserRepository {
private users: { [id: string]: User } = {};
async getUserById(id: string): Promise<User | null> {
return this.users[id] || null;
}
async saveUser(user: User): Promise<void> {
this.users[user.id] = user;
}
}
// application/user-service.ts
import { UserRepository, InMemoryUserRepository } from '../infrastructure/user-repository';
import { User } from '../domain/user';
export class UserService {
private userRepository: UserRepository;
constructor(userRepository: UserRepository) {
this.userRepository = userRepository;
}
async getUser(id: string): Promise<User | null> {
return this.userRepository.getUserById(id);
}
async createUser(name: string, email: string): Promise<User> {
const id = Math.random().toString(36).substring(2, 15); // generate a more robust ID for real applications
const user: User = { id, name, email };
await this.userRepository.saveUser(user);
return user;
}
}
// presentation/user-controller.ts
import { UserService } from '../application/user-service';
import { InMemoryUserRepository } from '../infrastructure/user-repository';
const userRepository = new InMemoryUserRepository();
const userService = new UserService(userRepository);
async function main() {
const newUser = await userService.createUser("John Doe", "john.doe@example.com");
const retrievedUser = await userService.getUser(newUser.id);
console.log(retrievedUser);
}
main();
"""
### 1.3 Hexagonal Architecture (Ports and Adapters)
**Standard:** Decouple the core business logic from external dependencies using ports and adapters.
* **Do This:** Define interfaces (ports) for interacting with external systems (databases, APIs, UI). Implement adapters that translate between these interfaces and the specific technologies.
* **Don't Do This:** Directly embed database queries or API calls within the core domain logic.
**Why:** Hexagonal architecture makes it easier to switch technologies, test the core logic in isolation, and adapt to changing requirements.
**Code Example:** Hexagonal Architecture using TypeScript interfaces & implementations
"""typescript
// Interface -> Port
interface PaymentGateway {
processPayment(amount: number, creditCard: string): Promise<boolean>;
}
// Implementation -> Adapter for Stripe
class StripePaymentGateway implements PaymentGateway {
async processPayment(amount: number, creditCard: string): Promise<boolean> {
// integrate with Stripe API here. For example:
console.log("Processing $${amount} via Stripe with credit card ${creditCard}");
return true; // Simulated result
}
}
// Implementation -> Adapter for PayPal
class PayPalPaymentGateway implements PaymentGateway {
async processPayment(amount: number, creditCard: string): Promise<boolean> {
// integrate with PayPal API here
console.log("Processing $${amount} via PayPal with credit card ${creditCard}");
return true; // Simulated result
}
}
// Core Application Logic
class PaymentService {
private paymentGateway: PaymentGateway;
constructor(paymentGateway: PaymentGateway) {
this.paymentGateway = paymentGateway;
}
async charge(amount: number, creditCard: string): Promise<boolean> {
return await this.paymentGateway.processPayment(amount, creditCard);
}
}
// Usage:
async function main() {
const stripeGateway = new StripePaymentGateway();
const payPalGateway = new PayPalPaymentGateway();
const paymentServiceStripe = new PaymentService(stripeGateway); // Inject Stripe
const paymentServicePayPal = new PaymentService(payPalGateway); // Inject PayPal
const stripeResult = await paymentServiceStripe.charge(100, "1234-5678-9012-3456");
const paypalResult = await paymentServicePayPal.charge(50, "9876-5432-1098-7654");
console.log("Stripe Payment Result: ${stripeResult}");
console.log("PayPal Payment Result: ${paypalResult}");
}
main();
"""
### 1.4 CQRS (Command Query Responsibility Segregation)
**Standard:** Separate read operations (queries) from write operations (commands).
* **Do This:** Define dedicated models and data access patterns for reading and writing data.
* **Don't Do This:** Use the same data model and database queries for both reads and writes, potentially leading to performance issues and complex code.
**Why:** CQRS allows optimizing read and write operations independently. This is particularly useful in scenarios with high read/write ratios.
**Code Example:** Implementing CQRS pattern in TypeScript
"""typescript
// Command: CreateUserCommand
interface CreateUserCommand {
type: 'CreateUser';
name: string;
email: string;
}
// Query: GetUserQuery
interface GetUserQuery {
type: 'GetUser';
id: string;
}
// Command Handler
class UserCommandHandler {
async handle(command: CreateUserCommand): Promise<string> {
if (command.type === 'CreateUser') {
// Create user logic (e.g., save to database)
const userId = Math.random().toString(36).substring(2, 15);
console.log("Creating user with name ${command.name} and email ${command.email} (ID: ${userId})");
return userId
// Return the new User ID
}
throw new Error('Invalid command');
}
}
// Query Handler
class UserQueryHandler {
async handle(query: GetUserQuery): Promise<{ id: string; name: string; email: string } | null > {
if (query.type === 'GetUser') {
// Retrieve user logic (e.g., fetch from database)
console.log("Retrieving user with ID ${query.id}");
return { id: query.id, name: 'Example User', email: 'user@example.com' };
}
throw new Error('Invalid query');
}
}
// Usage example:
async function main() {
const commandHandler = new UserCommandHandler();
const queryHandler = new UserQueryHandler();
const createUserCommand: CreateUserCommand = {
type: 'CreateUser',
name: 'John Doe',
email: 'john.doe@example.com'
};
const userId = await commandHandler.handle(createUserCommand); // creates a theoretical user
console.log("User created with ID: ${userId}");
const getUserQuery: GetUserQuery = {
type: 'GetUser',
id: userId // Using the generated user ID
};
const user = await queryHandler.handle(getUserQuery);
if(user){
console.log("Retrieved user: ${JSON.stringify(user)}");
} else {
console.log("User with ID ${getUserQuery.id} not found.");
}
}
main();
"""
## 2. Project Structure and Organization
### 2.1 Modular File Structure
**Standard:** Organize code into modules based on functionality, following a logical directory structure.
* **Do This:** Group related files (components, services, interfaces) within dedicated directories. Use meaningful names for files and directories. Consider feature-based or layer-based structuring.
* **Don't Do This:** Place all files in a single directory or create a folder structure that mirrors implementation details rather than business logic.
**Why:** A modular structure improves code discoverability, maintainability, and collaboration among developers.
"""
src/
├── components/ # Reusable UI components
│ ├── button/ # Specific Button component
│ │ ├── button.tsx # JSX code
│ │ ├── button.module.css # Styles using CSS modules
│ │ └── index.ts # Exports
│ ├── input/ # Specific Input component
│ │ ├── input.tsx # JSX code
│ │ ├── input.module.css # Styles using CSS modules
│ │ └── index.ts # Exports
│ └── index.ts # Exports all reusable components
├── services/ # Business logic and API interactions
│ ├── auth/ # Authentication-specific logic
│ │ ├── auth-service.ts # Authentication service
│ │ └── index.ts # Exports
│ ├── api/ # API client
│ │ ├── api-client.ts # API client
│ │ └── index.ts # Exports
│ └── index.ts # Exports all services
├── models/ # Data models / Types
│ ├── user.ts # User model
│ └── product.ts # Product model
├── utils/ # Utility functions
│ ├── helper-functions.ts # Various utility functions
│ └── index.ts # Exports
├── app.tsx # Main application component
├── styles/ # Global styles
│ └── global.css
├── index.tsx # Entry point
└── tsconfig.json # TypeScript configuration
"""
### 2.2 Explicit Dependencies
**Standard:** Declare all dependencies explicitly using "import" statements.
* **Do This:** Import only the necessary modules or functions. Use named imports where possible.
* **Don't Do This:** Rely on implicit global variables or wildcard imports ("import * as ...").
**Why:** Explicit dependencies allow for better code analysis, refactoring, and dependency management.
**Code Example:** Named vs. wildcard imports
"""typescript
// Good: Named imports
import { useState, useEffect } from 'react';
import { calculateTotal } from './utils';
// Bad: Wildcard imports (less explicit)
import * as React from 'react'; // Avoid unless necessary
import * as Utils from './utils';// Avoid unless necessary
"""
### 2.3 Single Responsibility Principle (SRP)
**Standard:** Each module (class, function, component) should have one, and only one, reason to change.
* **Do This:** Decompose complex functionalities into smaller, focused modules.
* **Don't Do This:** Create "god classes" or functions that handle multiple unrelated tasks.
**Why:** SRP simplifies code, improves testability, and reduces the risk of introducing unintended side effects when modifying existing code.
**Code Example:** Adhering to Single Responsibility Principle
"""typescript
// Good: Separate classes for user authentication and profile management
// Class to Handle Authentication
class AuthenticationService {
async login(username: string, password: string): Promise<boolean> {
// Authentication logic here
console.log("Authenticating user ${username}");
return true; // Simulate successful authentication
}
async logout(): Promise<void> {
// Logout logic here
console.log('Logging out user');
}
}
// Class to Handle User Profiles
class UserProfileService {
async getUserProfile(userId: string): Promise<{ id: string; username: string; }> {
// Logic to retrieve user profile
console.log("Fetching profile for user ID ${userId}");
return { id: userId, username: 'exampleUser' }; // Simulate profile data
}
async updateUserProfile(userId: string, newData: any): Promise<void> {
// Logic to update user profile
console.log("Updating profile for user ID ${userId} with data:", newData);
}
}
const authService = new AuthenticationService();
const profileService = new UserProfileService();
async function main() {
const isLoggedIn = await authService.login('user123', 'password');
if (isLoggedIn) {
const userProfile = await profileService.getUserProfile('user123');
console.log('User Profile:', userProfile);
}
}
main();
"""
### 2.4 Separation of Concerns (SoC)
**Standard:** Divide the application into distinct sections, each addressing a separate concern.
* **Do This:** Isolate UI rendering from data fetching, business logic from infrastructure code, etc.
* **Don't Do This:** Mix different concerns within the same module or component, leading to tightly coupled and difficult-to-maintain code.
**Why:** SoC promotes modularity, testability, and reusability.
**Code Example:** Separating data fetching concerns from UI
"""typescript
// Bad: Mixing data fetching with UI rendering within the same component
// (Leads to tight coupling and makes it difficult to test and reuse)
// Consider isolating data fetching logic using custom hooks or services.
import React, { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
email: string;
}
function UserProfileComponent({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchUser() {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/users/${userId}");
if (!response.ok) {
throw new Error("HTTP error! Status: ${response.status}");
}
const data: User = await response.json();
setUser(data);
} catch (error) {
console.error('Error fetching user:', error);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) {
return <div>Loading user profile...</div>;
}
if (!user) {
return <div>Failed to load user profile.</div>;
}
// Rendering the user profile
return (
<div>
<h2>User Profile</h2>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
</div>
);
}
export default UserProfileComponent;
// More robust example using custom hook
import React from 'react';
import { useUser } from './useUser'; // Custom hook
interface User {
id: number;
name: string;
email: string;
}
function UserProfileComponentHooked({ userId }: { userId: string }) {
const { user, loading, error } = useUser(userId); // Use the custom hook
if (loading) {
return <div>Loading user profile...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
if (!user) {
return <div>User not found.</div>;
}
return (
<div>
<h2>User Profile</h2>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
</div>
);
}
export default UserProfileComponentHooked;
// Custom hook
import { useState, useEffect } from 'react';
interface User {
id: number;
name: string;
email: string;
}
interface UseUserResult {
user: User | null;
loading: boolean;
error: Error | null;
}
export function useUser(userId: string): UseUserResult {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchUser() {
setLoading(true);
setError(null);
try {
const response = await fetch("https://jsonplaceholder.typicode.com/users/${userId}");
if (!response.ok) {
throw new Error("HTTP error! Status: ${response.status}");
}
const data: User = await response.json();
setUser(data);
} catch (e:any) {
setError(e);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
return { user, loading, error };
}
"""
## 3. Design Patterns
### 3.1 Factory Pattern
**Standard:** Use factory functions or classes to create objects, abstracting away the concrete implementation details.
* **Do This:** Define a factory interface or abstract class and create concrete factories that implement the interface.
* **Don't Do This:** Directly instantiate concrete classes throughout the codebase, tightly coupling code to specific implementations.
**Why:** The Factory Pattern promotes loose coupling and allows for easy substitution of object implementations.
**Code Example:** Factory pattern in typescript
"""typescript
// Interface for different payment methods
interface PaymentMethod {
processPayment(amount: number): void;
}
// Concrete implementation for Credit Card payment
class CreditCardPayment implements PaymentMethod {
processPayment(amount: number): void {
console.log("Processing credit card payment of $${amount}");
}
}
// Concrete implementation for PayPal payment
class PayPalPayment implements PaymentMethod {
processPayment(amount: number): void {
console.log("Processing PayPal payment of $${amount}");
}
}
// Factory interface
interface PaymentMethodFactory {
createPaymentMethod(): PaymentMethod;
}
// Concrete factory for creating Credit Card payments
class CreditCardPaymentFactory implements PaymentMethodFactory {
createPaymentMethod(): PaymentMethod {
return new CreditCardPayment();
}
}
// Concrete factory for creating PayPal payments
class PayPalPaymentFactory implements PaymentMethodFactory {
createPaymentMethod(): PaymentMethod {
return new PayPalPayment();
}
}
// The client code that uses the factory to create payment methods
class PaymentProcessor {
private factory: PaymentMethodFactory;
constructor(factory: PaymentMethodFactory) {
this.factory = factory;
}
processOrder(amount: number): void {
const paymentMethod = this.factory.createPaymentMethod();
paymentMethod.processPayment(amount);
}
}
// Usage
async function main() {
const creditCardFactory = new CreditCardPaymentFactory();
const payPalFactory = new PayPalPaymentFactory();
const paymentProcessor1 = new PaymentProcessor(creditCardFactory);
paymentProcessor1.processOrder(100); // Output: Processing credit card payment of $100
const paymentProcessor2 = new PaymentProcessor(payPalFactory);
paymentProcessor2.processOrder(50); // Output: Processing PayPal payment of $50
}
main();
"""
### 3.2 Observer Pattern
**Standard:** Define a one-to-many dependency between objects, so that when one object changes state, all its dependents are notified and updated automatically.
* **Do This:** Create a "Subject" interface that provides methods for attaching and detaching observers
* **Don't Do This:** Have tight coupling between objects by directly calling methods on other objects.
**Why:** The Observer Pattern decouples the subject from its observers, making it easier to add or remove observers without modifying the subject.
**Code Example:** Implementation
"""typescript
// Observer Interface
interface Observer {
update(message: string): void;
}
// Subject Interface
interface Subject {
attach(observer: Observer): void;
detach(observer: Observer): void;
notify(message: string): void;
}
// Concrete Observer
class ConcreteObserver implements Observer {
private id: number;
constructor(id: number) {
this.id = id;
}
update(message: string): void {
console.log("Observer ${this.id}: Received message - ${message}");
}
}
// Concrete Subject
class ConcreteSubject implements Subject {
private observers: Observer[] = [];
attach(observer: Observer): void {
this.observers.push(observer);
}
detach(observer: Observer): void {
this.observers = this.observers.filter(obs => obs !== observer);
}
notify(message: string): void {
this.observers.forEach(observer => observer.update(message));
}
// Method to update state and notify observers
someBusinessLogic(): void {
console.log('Subject: Doing something important.');
this.notify('Important message from Subject!');
}
}
// Usage
async function main() {
const subject = new ConcreteSubject();
const observer1 = new ConcreteObserver(1);
const observer2 = new ConcreteObserver(2);
const observer3 = new ConcreteObserver(3);
subject.attach(observer1);
subject.attach(observer2);
subject.someBusinessLogic();
subject.detach(observer2); // Detach observer2
subject.attach(observer3);
subject.someBusinessLogic();
}
main();
"""
### 3.3 Strategy Pattern
**Standard:** Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
* **Do This:** Create a strategy interface that defines a method the strategies must implement, then each concrete strategy implements this interface.
* **Don't Do This:** Hardcode the execution path.
**Why:** To encapsulate variation.
**Code Example:** Implementation
"""typescript
// Strategy Interface
interface SortStrategy {
sort(data: number[]): number[];
}
// Concrete Strategy 1: Bubble Sort
class BubbleSortStrategy implements SortStrategy {
sort(data: number[]): number[] {
console.log('Sorting using bubble sort');
// Bubble Sort implementation
const n = data.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - i - 1; j++) {
if (data[j] > data[j + 1]) {
// Swap data[j] and data[j+1]
const temp = data[j];
data[j] = data[j + 1];
data[j + 1] = temp;
}
}
}
return data;
}
}
// Concrete Strategy 2: Quick Sort
class QuickSortStrategy implements SortStrategy {
sort(data: number[]): number[] {
console.log('Sorting using quick sort');
// Quick Sort implementation
if (data.length <= 1) {
return data;
}
const pivot = data[0];
const left = [];
const right = [];
for (let i = 1; i < data.length; i++) {
if (data[i] < pivot) {
left.push(data[i]);
} else {
right.push(data[i]);
}
}
return this.sort(left).concat(pivot, this.sort(right));
}
}
// Context
class Sorter {
private strategy: SortStrategy;
constructor(strategy: SortStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: SortStrategy): void {
this.strategy = strategy;
}
sort(data: number[]): number[] {
return this.strategy.sort(data);
}
}
// Usage
async function main() {
const data = [5, 2, 8, 1, 9, 4];
const bubbleSort = new BubbleSortStrategy();
const quickSort = new QuickSortStrategy();
const sorter = new Sorter(bubbleSort); // Initial strategy is Bubble Sort
console.log('Sorted array using Bubble Sort:', sorter.sort([...data]));
sorter.setStrategy(quickSort); // Change the strategy to Quick Sort
console.log('Sorted array using Quick Sort:', sorter.sort([...data]));
}
main();
"""
## 4. TypeScript Specific Considerations
### 4.1 Strict Mode
**Standard:** Enable TypeScript's strict mode ("strict: true" in "tsconfig.json").
* **Do This:** Embrace strict null checks, no implicit "any", and other strictness flags. Fix related typing issues.
* **Don't Do This:** Disable strict mode to avoid compiler errors, as this can hide potential runtime bugs.
**Why:** Strict mode enforces stricter type checking, leading to more robust and maintainable code.
### 4.2 Utility Types
**Standard:** Leverage TypeScript's utility types (e.g., "Partial", "Readonly", "Pick", "Omit") to manipulate types and improve code clarity.
"""typescript
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties readonly
type ReadonlyUser = Readonly<User>;
// Pick only id and name properties
type UserName = Pick<User, 'id' | 'name'>;
// Omit the createdAt property
type UserWithoutCreatedAt = Omit<User, 'createdAt'>;
"""
### 4.3 Type Inference
**Standard:** Take advantage of TypeScript's type inference capabilities to reduce boilerplate and improve code readability.
**Code Example:**
"""typescript
// TypeScript infers the type of 'message' to be string
const message = "Hello, TypeScript!";
// TypeScript infers the return type of the function
function add(a: number, b: number) {
return a + b;
}
"""
### 4.4 Declaration Files
**Standard:** Provide declaration files (".d.ts") for libraries or modules to enable type checking and code completion for consumers.
* **Do This:** Use automatic declaration generation ("declaration: true" in "tsconfig.json") or manually create declaration files when necessary.
* **Don't Do This:** Ship JavaScript code without corresponding declaration files, limiting the usability of the code in TypeScript projects.
### 4.5 Advanced Types
**Standard:** Use advanced TypeScript features like discriminated unions, conditional types, and mapped types where appropriate to model complex data structures and relationships.
"""typescript
// Discriminated Union
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
}
}
"""
### 4.6 Decorators
**Standard:** Use decorators to add metadata or modify the behavior of classes, methods, or properties, but adhere to a consistent style, avoid complex implementations, and be aware of potential performance implications.
"""typescript
function LogClass(constructor: Function) {
console.log("Class ${constructor.name} is being decorated.");
}
@LogClass
class MyClass {
constructor() {
console.log('MyClass constructor called.');
}
}
function LogMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function(...args: any[]) {
console.log("Method ${propertyKey} is being called with arguments:", args);
const result = originalMethod.apply(this, args);
console.log("Method ${propertyKey} returned:", result);
return result;
};
return descriptor;
}
class Calculator {
@LogMethod
add(a: number, b: number): number {
return a + b;
}
}
"""
### 4.7 Use "unknown" type where appropriate
**Standard:** Use "unknown" instead of "any" when you need to represent a value of any type but want to ensure type safety.
"unknown" forces you to perform type narrowing before using the value, providing better type safety than casually using "any", while not necessarily knowing the implementation types at compile time.
"""typescript
function processData(data: unknown): void {
if (typeof data === 'string') {
console.log(data.toUpperCase()); // OK, data is string here
} else if (typeof data === 'number') {
console.log(data * 2); // OK, data is number here
} else {
console.log('Data is of unknown type');
}
}
"""
These core architecture standards provide a comprehensive foundation for building robust, scalable, and maintainable TypeScript applications. By adhering to these guidelines, development teams can improve code quality, reduce technical debt, and accelerate development.
danielsogl
Created Mar 6, 2025
# Component Design Standards for TypeScript
This document outlines the component design standards for TypeScript, focusing on creating reusable, maintainable, and efficient components. These standards aim to guide developers in building robust and scalable applications using modern TypeScript practices.
## I. General Component Design Principles
### 1. Reusability
**Standard:** Design components to be reusable across different parts of the application or even different projects.
**Why:** Reusability reduces code duplication, simplifies maintenance, and promotes consistency.
**Do This:**
* **Parameterize:** Accept properties (props) to configure behavior and appearance.
* **Separate Concerns:** Avoid tightly coupling components to specific application contexts.
* **Use Interfaces:** Define clear interfaces for props and component interactions.
**Don't Do This:**
* Hardcode values that could vary in different contexts.
* Include business logic that is specific to one part of the application within a generic component.
**Example:**
"""typescript
// Good - Reusable Button component
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary";
}
const Button: React.FC<ButtonProps> = ({ label, onClick, variant = "primary" }) => {
return (
<button className={"button ${variant}"} onClick={onClick}>
{label}
</button>
);
};
// Bad - Button tightly coupled to a specific event
const DeleteButton = () => {
const handleDelete = () => {
// Complex delete logic here... tightly coupled to a specific part of the app
}
return <button onClick={handleDelete}>Delete Item</button>
}
"""
### 2. Maintainability
**Standard:** Write components that are easy to understand, modify, and debug.
**Why:** Well-maintained code reduces the cost of future development and minimizes the risk of introducing bugs.
**Do This:**
* **Keep Components Small:** Break down large components into smaller, more manageable pieces.
* **Use Descriptive Names:** Name components, props, and methods clearly and consistently.
* **Add Comments:** Explain complex logic or non-obvious behavior.
* **Follow SOLID Principles:** Adhere to the SOLID principles of object-oriented design where applicable.
**Don't Do This:**
* Create monolithic components that are hard to navigate and understand.
* Use cryptic or ambiguous names.
* Neglect to document complex or critical sections of code.
"""typescript
// Good - Small and focused component
interface InputProps {
label: string;
value: string;
onChange: (newValue: string) => void;
type?: string;
}
const Input: React.FC<InputProps> = ({ label, value, onChange, type = "text" }) => {
return (
<div>
<label htmlFor={label}>{label}</label>
<input type={type} id={label} value={value} onChange={(e) => onChange(e.target.value)} />
</div>
);
};
// Bad - Large component with multiple responsibilities
const UserForm = () => {
//Lots of state management, validation, and rendering logic inside one component = difficult to maintain
return (
<div>
{/* Form elements and logic for handling user input, validation, and submission */}
</div>
)
}
"""
### 3. Composability
**Standard:** Design components that can be easily composed together to create more complex UI elements.
**Why:** Composition allows developers to build complex functionalities by combining simple, independent components.
**Do This:**
* **Use Children Props:** Allow components to render child elements passed as props.
* **Provide Configuration Options:** Offer flexibility in how components can be composed.
* **Design for Extensibility:** Allow adding new features or behaviors without modifying existing code.
**Don't Do This:**
* Prevent components from being nested or combined with other components.
* Make assumptions about the parent or child components.
"""typescript
// Good - Component that accepts children
interface LayoutProps {
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({ children }) => {
return (
<div className="layout">
<header>Header</header>
<main>{children}</main>
<footer>Footer</footer>
</div>
);
};
// Usage
<Layout>
<p>Content inside the layout</p>
</Layout>
// Bad - Component that tightly controls its content
const RestrictedLayout = () => {
return (
<div>
{/* Only allows specific content, hindering composability */}
<p>Specific content here</p>
</div>
)
}
"""
## II. TypeScript-Specific Standards
### 1. Explicit Typing
**Standard:** Use explicit types for all component props, state variables, and return types.
**Why:** TypeScript's static typing helps catch errors early, improves code readability, and makes refactoring easier.
**Do This:**
* Always define interfaces or types for component props.
* Use type annotations for all state variables.
* Specify return types for functions and methods.
* Leverage TypeScript's "unknown" and "any" types judiciously.
**Don't Do This:**
* Rely on implicit "any" types.
* Avoid type annotations altogether.
"""typescript
// Good - Explicitly typed component
interface ProfileProps {
name: string;
age: number;
occupation: string;
}
const Profile: React.FC<ProfileProps> = ({ name, age, occupation }) => {
return (
<div>
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Occupation: {occupation}</p>
</div>
);
};
// Example of using "unknown"
function processData(data: unknown): void {
if (typeof data === 'string') {
console.log(data.toUpperCase());
} else if (typeof data === 'number') {
console.log(data * 2);
} else {
console.log("Unsupported data type.")
}
}
// Bad - Implicit any types
const BadProfile = (props) => {
return (
<div>
<h2>{props.name}</h2> // 'props' has an implicit 'any' type.
<p>Age: {props.age}</p>
<p>Occupation: {props.occupation}</p>
</div>
);
};
"""
### 2. Interface vs. Type
**Standard:** Use interfaces to define the shape of objects, and types for type aliases and unions.
**Why:** Interfaces are generally preferred for defining object shapes because they are more extensible and mergeable.
**Do This:**
* Use "interface" for describing the structure of component props and state.
* Use "type" for creating aliases, unions, and mapped types.
**Don't Do This:**
* Inconsistently use "interface" and "type" without a clear rationale.
"""typescript
// Good - Using interface for props
interface ProductProps {
name: string;
price: number;
description?: string;
}
const Product: React.FC<ProductProps> = ({ name, price, description }) => {
return (
<div>
<h3>{name}</h3>
<p>Price: ${price}</p>
{description && <p>{description}</p>}
</div>
);
};
// Good - Using type for union
type ButtonVariant = "primary" | "secondary" | "tertiary";
interface ButtonProps {
label: string;
onClick: () => void;
variant: ButtonVariant;
}
const NewButton: React.FC<ButtonProps> = ({ label, onClick, variant }) => {
return (
<button className={"button ${variant}"} onClick={onClick}>
{label}
</button>
);
};
// Bad - Inconsistent use
type AnotherProductProps = {
name: string;
price: number;
};
"""
### 3. Generics
**Standard:** Use generics to create reusable components that can work with different types of data.
**Why:** Generics provide type safety while maintaining flexibility, reducing the need for type casting and improving code reusability.
**Do This:**
* Use generics for components that operate on different data types.
* Provide default type parameters when appropriate.
**Don't Do This:**
* Avoid using generics when they can provide better type safety.
* Overuse generics, making code unnecessarily complex.
"""typescript
// Good - Generic List component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
}
const List = <T,>({ items, renderItem }: ListProps<T>) => {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{renderItem(item)}</li>
))}
</ul>
);
};
// Usage
interface User {
id: number;
name: string;
}
const users: User[] = [{ id: 1, name: "John" }, { id: 2, name: "Jane" }];
const UserList = () => {
return (
<List<User>
items={users}
renderItem={(user) => (
<div>
{user.id}: {user.name}
</div>
)}
/>
);
};
// Bad - Not using generics
interface NumberListProps {
items: number[];
renderItem: (item: number) => React.ReactNode;
}
"""
### 4. Utility Types
**Standard:** Utilize TypeScript's utility types ("Partial", "Readonly", "Pick", "Omit", "Record", etc.) to manipulate types effectively.
**Why:** Utility types simplify type transformations and reduce boilerplate code.
**Do This:**
* Use "Partial<T>" to create a type where all properties of "T" are optional.
* Use "Readonly<T>" to create a type where all properties of "T" are read-only.
* Use "Pick<T, K>" to create a type by picking a set of properties "K" from "T".
* Use "Omit<T, K>" to create a type by excluding a set of properties "K" from "T".
* USE "Record<K, T>" to create a type defining an object with key type "K" and value type "T".
**Don't Do This:**
* Manually create types that can be derived using utility types.
* Overuse utility types, leading to overly complex type definitions.
"""typescript
// Good - Using utility types
interface Task {
id: number;
title: string;
completed: boolean;
}
// Partial<Task> - all props optional
type PartialTask = Partial<Task>;
// Readonly<Task> - all props readonly
type ReadonlyTask = Readonly<Task>;
// Pick<Task, 'id' | 'title'> - only id and title
type TaskIdAndTitle = Pick<Task, 'id' | 'title'>;
// Omit<Task, 'completed'> - all but completed
type TaskWithoutCompleted = Omit<Task, 'completed'>;
// Record<string, number> - object with string keys and number values
type StringNumberMap = Record<string, number>;
// Bad - Manually creating similar types
interface BadPartialTask {
id?: number;
title?: string;
completed?: boolean;
}
"""
### 5. Enums
**Standard:** Use enums for defining a set of named constants, but be mindful of their limitations in TypeScript. Consider using union types with const assertions for more flexibility.
**Why:** Enums provide a way to organize and document related values, improving code readability.
**Do This:**
* Use enums for representing a fixed set of options.
* Use const enums to avoid generating unnecessary JavaScript code.
**Don't Do This:**
* Overuse enums when union types or literal types might be more appropriate.
* Rely on enums for values that may change frequently.
"""typescript
// Good - Using enum
enum Color {
Red,
Green,
Blue,
}
interface ColoredItem {
name: string;
color: Color;
}
function printColor(item: ColoredItem) {
console.log("The color of ${item.name} is ${Color[item.color]}");
}
const apple: ColoredItem = { name: "Apple", color: Color.Red };
printColor(apple);
// const enum - inlined
const enum Direction {
Up,
Down,
Left,
Right
}
function move(dir: Direction) {
// ...
}
move(Direction.Left); // Access compiles directly to the number "2" with no enum object
"""
"""typescript
// Alternative with Union Types and Const Assertions (more modern approach)
const Status = {
OPEN: 'OPEN',
IN_PROGRESS: 'IN_PROGRESS',
CLOSED: 'CLOSED',
} as const;
type StatusType = typeof Status[keyof typeof Status];
interface Task {
status: StatusType;
}
const newTask:Task = {status: Status.OPEN}
"""
### 6. Nullability and Optional Properties
**Standard:** Handle null and undefined values explicitly to prevent runtime errors. Utilize optional properties and non-null assertion operators judiciously.
**Why:** Explicit handling of nullability improves code safety and reliability.
**Do This:**
* Use optional properties ("?") to indicate that a property may be undefined.
* Use union types ("string | undefined") to allow a property to be either a specific type or undefined.
* Use non-null assertion operators ("!") only when you are absolutely sure that a value is not null or undefined.
**Don't Do This:**
* Ignore potential null or undefined values.
* Overuse non-null assertion operators without proper justification.
"""typescript
// Good - Handling nullability
interface Config {
apiUrl: string;
timeout?: number; // Optional property
}
function fetchData(config: Config) {
const timeoutValue = config.timeout ?? 5000; // Default value if undefined. Nullish coalescing operator
console.log("Timeout: ${timeoutValue}");
console.log("API URL: ${config.apiUrl}")
}
// Bad - Ignoring nullability
function potentiallyReturnsNull(input: string): string | null {
if (input === "error") return null
return "Valid"
}
const result = potentiallyReturnsNull("test");
//console.log(result.toUpperCase()); // Potential error: result might be null
if (result) {
console.log(result.toUpperCase()) // safe
}
"""
### 7. React Component Specifics
**Standard:** When using TypeScript with React, follow established best practices for typing components, hooks, and events.
**Why:** React, combined with TypeScript, introduces its own set of type-related challenges that require specific solutions.
**Do This:**
* Use "React.FC" (or "React.FunctionComponent") to define functional components with type safety. Note however, that explicit props typing is frequently preferred over "React.FC" as it provides more control and readability.
* Use "React.useState", "React.useEffect", and other hooks with proper type annotations.
* Type event handlers correctly using "React.ChangeEvent", "React.MouseEvent", etc.
**Don't Do This:**
* Use JavaScript-style React components without any TypeScript annotations.
* Ignore the types of event handlers or hooks.
* Use "any" excessively in React components, especially for event handlers.
"""typescript
// Good - React component with TypeScript
import React, { useState, useEffect } from 'react';
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = ({ initialCount }) => {
const [count, setCount] = useState<number>(initialCount);
useEffect(() => {
console.log('Count updated:', count);
}, [count]);
const increment = () => {
setCount((prevCount) => prevCount + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
// Properly typed event handler
const InputComponent : React.FC = () => {
const [inputValue, setInputValue] = useState<string>("");
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
}
return <input type="text" value={inputValue} onChange={handleChange} />
}
// Bad - React component without TypeScript
const BadCounter = (props) => { // Implicit any
const [count, setCount] = useState(props.initialCount); // Implicit any
useEffect(() => {
console.log('Count updated:', count);
}, [count]);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
"""
### 8. Modern ECMAScript Features
**Standard**: Embrace modern ECMAScript features supported by TypeScript to write concise and expressive code.
**Why:** Modern features improve code readability and reduce boilerplate, leading to more maintainable and efficient components.
**Do This:**
* Use arrow functions for concise function definitions, especially for callbacks.
* Use destructuring to extract values from objects and arrays.
* Use the spread operator ("...") to create copies of objects and arrays.
* Use template literals for string interpolation.
* Use optional chaining ("?.") to safely access nested properties.
* Use nullish coalescing operator ("??") to provide default values for null or undefined values.
**Don't Do This:**
* Avoid using modern features because of unfamiliarity.
* Overuse features to make code hard to understand.
"""typescript
// Good - Modern ECMAScript features
interface Person {
firstName: string;
lastName?: string;
address?: {
city: string;
country: string;
}
}
const greet = (person: Person) => {
const { firstName, lastName = "Doe" } = person; // Destructuring with default value
const city = person.address?.city ?? "Unknown City"; // Optional chaining and nullish coalescing
return "Hello, ${firstName} ${lastName} from ${city}!";
};
const user: Person = { firstName: "John", address: { city: "New York", country: "USA" } };
console.log(greet(user));
const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4, 5]; // Spread operator
"""
## III. Advanced Component Design Patterns
### 1. Higher-Order Components (HOCs)
**Standard:** Use HOCs to share logic between components, but be aware of the potential for prop name collisions and decreased readability. Consider alternatives like render props or hooks in modern React.
**Why:** HOCs enhance code reusability by wrapping components with additional functionality.
**Do This:**
* Use HOCs for cross-cutting concerns like authentication, logging, or data fetching.
* Ensure HOCs pass through relevant props to the wrapped component.
**Don't Do This:**
* Overuse HOCs, leading to deeply nested component trees.
* Create HOCs that tightly couple the wrapped component to specific logic.
* Shadow original props.
"""typescript
// Good - Higher-Order Component
function withLogging<P extends object>(WrappedComponent: React.ComponentType<P>) {
return (props: P) => {
console.log("Component is rendering:", WrappedComponent.name);
return <WrappedComponent {...props} />;
};
}
interface MyComponentProps {
name: string;
}
const MyComponent: React.FC<MyComponentProps> = ({ name }) => {
return <div>Hello, {name}!</div>;
};
const EnhancedComponent = withLogging(MyComponent);
// Usage: <EnhancedComponent name="John" />
"""
### 2. Render Props
**Standard:** Use render props to share rendering logic between components. Consider using hooks as a more modern and flexible alternative.
**Why:** Render props provide a way to inject custom rendering behavior into a component.
**Do This:**
* Parameterize the render prop with the necessary data or functions.
* Keep the render function pure and predictable.
**Don't Do This:**
* Create render props with complex side effects.
* Hardcode the render prop name.
"""typescript
// Good - Render Props
interface MouseTrackerProps {
render: (props: { x: number; y: number }) => React.ReactNode;
}
class MouseTracker extends React.Component<MouseTrackerProps> {
state = { x: 0, y: 0 };
handleMouseMove = (event: React.MouseEvent) => {
this.setState({
x: event.clientX,
y: event.clientY,
});
};
render() {
return (
<div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
{this.props.render(this.state)}
</div>
);
}
}
// Usage:
// <MouseTracker render={({ x, y }) => <h1>Mouse position: {x}, {y}</h1>} />
"""
### 3. Custom Hooks
**Standard:** Use custom hooks to extract stateful logic and side effects from functional components.
**Why:** Custom hooks promote code reusability and simplify component logic.
**Do This:**
* Name custom hooks with the "use" prefix.
* Use hooks to encapsulate complex state management or side effects.
* Ensure custom hooks are pure and predictable.
**Don't Do This:**
* Call hooks outside of functional components or other hooks.
* Create overly complex hooks that are difficult to maintain.
"""typescript
// Good - Custom Hook - Fetch Data
import { useState, useEffect } from 'react';
function useFetch<T>(url: string): { data: T | null; loading: boolean; error: string | null } {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
const json = await response.json();
setData(json);
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
// Usage
interface Data {
userId: number;
id: number;
title: string;
completed: boolean;
}
function MyComponent() {
const { data, loading, error } = useFetch<Data[]>('https://jsonplaceholder.typicode.com/todos');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{data?.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
"""
## IV. Performance Optimization
### 1. Memoization
**Standard:** Use "React.memo" for functional components and "shouldComponentUpdate" for class components to prevent unnecessary re-renders.
**Why:** Memoization optimizes performance by skipping re-renders when the props have not changed.
**Do This:**
* Wrap pure functional components with "React.memo".
* Implement "shouldComponentUpdate" in class components to compare props and state.
**Don't Do This:**
* Memoize components that are frequently updated.
* Forget to compare all relevant props and state in "shouldComponentUpdate".
"""typescript
// Good - Memoization
import React from 'react';
interface MyComponentProps {
name: string;
onClick: () => void;
}
const MyComponent: React.FC<MyComponentProps> = ({ name, onClick }) => {
console.log('MyComponent is rendering');
return (
<div onClick={onClick}>
Hello, {name}!
</div>
);
};
const MemoizedComponent = React.memo(MyComponent);
// Usage
// <MemoizedComponent name="John" onClick={() => console.log('Clicked')} />
"""
### 2. Code Splitting
**Standard:** Use dynamic "import()" statements to split code into smaller chunks that are loaded on demand.
**Why:** Code splitting reduces the initial load time of the application.
**Do This:**
* Split large components or modules into separate chunks.
* Use "React.lazy" and "Suspense" for lazy-loading components.
**Don't Do This:**
* Create too many small chunks, leading to excessive network requests.
* Forget to provide a fallback UI while loading the chunks.
"""typescript
// Good - Code Splitting
import React, { Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./MyComponent'));
function MyPage() {
return (
<div>
<h1>My Page</h1>
<Suspense fallback={<p>Loading...</p>}>
<LazyComponent />
</Suspense>
</div>
);
}
"""
## V. Security Considerations
### 1. Input Validation
**Standard:** Validate all user inputs to prevent security vulnerabilities like cross-site scripting (XSS) and SQL injection.
**Why:** Input validation ensures that only valid data is processed by the application.
**Do This:**
* Use server-side validation for critical data.
* Sanitize user inputs to remove potentially harmful characters.
**Don't Do This:**
* Trust user inputs without validation.
* Rely solely on client-side validation.
### 2. Secure Data Handling
**Standard:** Store sensitive data securely and avoid exposing it in client-side code.
**Why:** Secure data handling protects user information from unauthorized access.
**Do This:**
* Use encryption to protect sensitive data.
* Store API keys and secrets securely in environment variables.
* Avoid storing sensitive data in local storage or cookies.
###3. Dependency Management
**Standard:** Regularly audit and update dependencies to address known security vulnerabilities.
**Why:** Using outdated dependencies can expose your application to known security exploits.
**Do This:**
* Use tools like "npm audit" or "yarn audit" to identify vulnerabilities in your dependencies.
* Regularly update dependencies to their latest versions, or apply security patches when available.
"""bash
npm audit
yarn audit
"""
## VI. Conclusion
Adhering to these component design standards will help developers build robust, maintainable, and efficient TypeScript applications. By focusing on reusability, maintainability, composability, and security, development teams can create high-quality software that meets the needs of their users. Continuously reviewing and updating these standards will ensure they remain relevant and effective as the TypeScript ecosystem evolves.
danielsogl
Created Mar 6, 2025
# State Management Standards for TypeScript
This document outlines the standards for managing application state in TypeScript projects. It emphasizes modern approaches, best practices, and patterns to ensure maintainable, performant, and scalable applications.
## 1. General Principles
### 1.1. Immutability
* **Do This:** Prefer immutable data structures whenever possible.
* **Don't Do This:** Mutate state directly.
**Why:** Immutability simplifies reasoning about state changes, prevents unexpected side effects, and enhances debugging. It also enables techniques like time-travel debugging and optimized rendering in UI frameworks.
**Code Example:**
"""typescript
// Immutable update using the spread operator
interface User {
id: number;
name: string;
age: number;
}
let user: User = { id: 1, name: "Alice", age: 30 };
// Good: Create a new object with the updated age
let updatedUser: User = { ...user, age: 31 };
// Bad: Mutating the original object directly
// user.age = 31; // Avoid this
"""
### 1.2. Predictable State Transitions
* **Do This:** Use explicit actions or events to trigger state changes.
* **Don't Do This:** Rely on implicit or hidden state mutations.
**Why:** Predictable state transitions make it easier to understand how the application's state evolves over time, leading to increased maintainability and debuggability.
**Code Example (using a Redux-like pattern):**
"""typescript
// Define action types
type Action =
| { type: "INCREMENT" }
| { type: "DECREMENT" };
// Define the reducer function
type CounterState = { count: number };
const initialState: CounterState = { count: 0 };
function counterReducer(state: CounterState = initialState, action: Action): CounterState {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + 1 };
case "DECREMENT":
return { ...state, count: state.count - 1 };
default:
return state;
}
}
// Example usage (simplified)
let currentState = initialState;
currentState = counterReducer(currentState, { type: "INCREMENT" });
console.log(currentState); // Output: { count: 1 }
"""
### 1.3. Single Source of Truth
* **Do This:** Centralize application state into a single store or context.
* **Don't Do This:** Scatter state across multiple components or services without a clear hierarchy.
**Why:** A single source of truth ensures consistency and avoids data duplication. It makes it easier to manage complex state interactions and provides a clear picture of the application's overall state.
### 1.4. Separation of Concerns
* **Do This:** Separate state management logic from UI components and business logic.
* **Don't Do This:** Mix state updates directly within UI event handlers or business logic functions.
**Why:** Separation of concerns enhances testability, reusability, and maintainability. It allows you to modify state management mechanisms without affecting the rest of the application.
## 2. State Management Libraries and Patterns
### 2.1. Context API with "useReducer" (for simple to moderately complex state)
* **Do This:** Use the "useReducer" hook in conjunction with the Context API for managing localized, moderately complex state in React components.
* **Don't Do This:** Overuse the Context API for global state management in large applications, as it can lead to performance issues due to unnecessary re-renders.
**Why:** Well-suited for managing state that is localized to a specific part of the application. "useReducer" provides a structured way to update state, similar to Redux, but without the overhead of a global store. The Context API provides a means to distribute and consume state across React components.
**Code Example:**
"""typescript
import React, { createContext, useContext, useReducer } from 'react';
// Define the state type and action types
interface AuthState {
isAuthenticated: boolean;
user: { id: number; username: string } | null;
}
type AuthAction =
| { type: 'LOGIN'; payload: { id: number; username: string } }
| { type: 'LOGOUT' };
// Define the reducer function
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case 'LOGIN':
return { ...state, isAuthenticated: true, user: action.payload };
case 'LOGOUT':
return { ...state, isAuthenticated: false, user: null };
default:
return state;
}
};
// Create the context
interface AuthContextType {
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Create the provider component
interface AuthProviderProps {
children: React.ReactNode;
}
const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [state, dispatch] = useReducer(authReducer, { isAuthenticated: false, user: null });
return (
<AuthContext.Provider value={{ state, dispatch }}>
{children}
</AuthContext.Provider>
);
};
// Create a custom hook to consume the context
const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export { AuthProvider, useAuth };
// Usage in a component
const LoginComponent: React.FC = () => {
const { dispatch } = useAuth();
const handleLogin = () => {
// Simulate login
dispatch({ type: 'LOGIN', payload: { id: 1, username: 'testuser' } });
};
return (
<button onClick={handleLogin}>Login</button>
);
};
"""
### 2.2. Redux (for complex, application-wide state management)
* **Do This:** Use Redux for managing global application state, especially in large, complex applications. Combine with Redux Toolkit to simplify configuration and reduce boilerplate.
* **Don't Do This:** Use Redux for simple, component-local state. Opt for "useState" or "useReducer" in those cases. Avoid overly complex or deeply nested state structures that can hurt performance.
**Why:** Redux provides a centralized store, predictable state transitions, and a rich ecosystem of middleware and tools. Redux Toolkit provides conventions and utilities which make Redux easier to use.
**Code Example (using Redux Toolkit):**
"""typescript
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
// Define the state type
interface CounterState {
value: number;
}
// Define the initial state
const initialState: CounterState = {
value: 0,
};
// Create a slice
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
},
},
});
// Export actions and reducer
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export const counterReducer = counterSlice.reducer;
// Configure the store
const store = configureStore({
reducer: {
counter: counterReducer,
},
});
export default store;
// Define RootState type
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Custom hooks for useSelector and useDispatch with proper typing
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
// Usage in a component
import { useAppDispatch, useAppSelector } from './store';
const Counter: React.FC = () => {
const count = useAppSelector((state) => state.counter.value);
const dispatch = useAppDispatch();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>Increment</button>
<button onClick={() => dispatch(decrement())}>Decrement</button>
<button onClick={() => dispatch(incrementByAmount(5))}>Increment by 5</button>
</div>
);
};
export default Counter;
"""
### 2.3. Zustand (for manageable complexity and performance)
* **Do This:** Consider Zustand as a simple, unopinionated state management solution. Suitable for applications where Redux might be overkill but "useReducer" is insufficient. Leverage selectors to optimize component re-renders.
* **Don't Do This:** Use Zustand for highly complex global state scenarios requiring advanced features (e.g., time travel debugging, middleware). Over-rely on global state when component-local state is more appropriate.
**Why:** Zustand is known for its simplicity and ease of use. It combines the benefits of mutable state with controlled updates, leading to good performance. It's an excellent middle-ground option. Selectors ensure components can subscribe to specific parts of the store, reducing unnecessary re-renders.
**Code Example:**
"""typescript
import { create } from 'zustand';
interface BearState {
bears: number;
increasePopulation: () => void;
removeAllBears: () => void;
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
// Example consumer
import useBearStore from './store';
const BearCounter = () => {
const bears = useBearStore((state) => state.bears); // Select only what is needed
return <h1>{bears} around here ...</h1>;
};
const Controls = () => {
const increasePopulation = useBearStore((state) => state.increasePopulation);
const removeAllBears = useBearStore((state) => state.removeAllBears);
return (
<>
<button onClick={increasePopulation}>one up</button>
<button onClick={removeAllBears}>remove all</button>
</>
)
}
"""
### 2.4. Jotai (for atomic state management)
* **Do This:** Explore Jotai for its approach to state management based on atomic, derived state. It is valuable when needing fine-grained control over state dependencies.
* **Don't Do This:** Attempt to port large Redux codebases to Jotai without careful consideration. Avoid creating excessively granular atoms that lead to performance bottlenecks.
**Why:** Jotai is particularly well-suited for scenarios where components need to subscribe to very specific parts of the state, minimizing re-renders and improving performance. It promotes composition and code reuse.
**Code Example:**
"""typescript
import { atom, useAtom } from 'jotai';
// Create an atom
const countAtom = atom(0);
// Example consumer
const CounterComponent = () => {
const [count, setCount] = useAtom(countAtom);
return (
<div>
Count: {count}
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
export default CounterComponent;
"""
### 2.5. XState (for managing complex state machines)
* **Do This:** Use XState for orchestrating complex, stateful logic, like multi-step forms, workflows, or UI interactions with clearly defined states and transitions. Leverage TypeScript's type system to fully define state structures and events.
* **Don't Do This:** Overuse XState for simple UI element state. Avoid creating state machines that are excessively complex or difficult to understand.
**Why:** XState helps to manage complexity by providing a visual and declarative way to define states and transitions. TypeScript integration further enhances the development experience and ensures type safety.
**Code Example:**
"""typescript
import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';
// Define the state machine context
interface ContextType {
count: number;
}
// Define the state machine events
type EventType =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' };
// Create the state machine
const counterMachine = createMachine<ContextType, EventType>(
{
id: 'counter',
initial: 'idle',
context: {
count: 0,
},
states: {
idle: {
on: {
INCREMENT: {
actions: assign({ count: (context) => context.count + 1 }),
},
DECREMENT: {
actions: assign({ count: (context) => context.count - 1 }),
},
},
},
},
}
);
// React component using the state machine
const CounterComponent = () => {
const [state, send] = useMachine(counterMachine);
return (
<div>
Count: {state.context.count}
<button onClick={() => send('INCREMENT')}>Increment</button>
<button onClick={() => send('DECREMENT')}>Decrement</button>
</div>
);
};
export default CounterComponent;
"""
## 3. Reactive Programming with RxJS
### 3.1. Observables for Asynchronous Data Streams
* **Do This:** Use RxJS Observables to handle asynchronous data streams, user input, and event-driven interactions.
* **Don't Do This:** Use RxJS for simple, synchronous operations that can be handled with standard JavaScript methods. Overuse or abuse Subjects, leading to uncontrolled side effects.
**Why:** Observables provide a powerful and flexible way to manage asynchronous data over time. RxJS offers a wide range of operators for transforming, filtering, and combining streams of data.
**Code Example:**
"""typescript
import { fromEvent, interval } from 'rxjs';
import { map, filter, take, scan } from 'rxjs/operators';
// Create an observable from a DOM event
const button = document.getElementById('myButton');
if (button) {
const click$ = fromEvent(button, 'click'); // Ensure button isn't null
click$.pipe(
map(() => 1),
scan((acc, val) => acc + val, 0)
).subscribe(count => {
console.log("Button clicked ${count} times");
});
}
// Create an observable from an interval
const interval$ = interval(1000);
interval$.pipe(
filter(value => value % 2 === 0),
map(value => "Tick: ${value}"),
take(5)
).subscribe(message => {
console.log(message);
});
"""
### 3.2. Subjects for Multicasting
* **Do This:** Use RxJS Subjects to multicast values to multiple observers. Use "BehaviorSubject" or "ReplaySubject" when you need to provide an initial value or replay past values, respectively.
* **Don't Do This:** Expose Subjects directly to components. Encapsulate them within services or state management solutions to control the flow of data.
**Why:** Subjects act as both an Observable and an Observer, allowing you to push values to multiple subscribers. They are useful for creating shared data streams or event buses.
**Code Example:**
"""typescript
import { Subject } from 'rxjs';
// Create a Subject
const mySubject = new Subject<string>();
// Subscribe to the Subject
mySubject.subscribe(value => {
console.log("Observer 1: ${value}");
});
mySubject.subscribe(value => {
console.log("Observer 2: ${value}");
});
// Push values to the Subject
mySubject.next('Hello');
mySubject.next('World');
"""
## 4. Practical Implementation Considerations
### 4.1. TypeScript Typing
* **Do This:** Use strong typing to define the shape of your state, actions, and reducers.
* **Don't Do This:** Use "any" or "unknown" types excessively, which defeats the purpose of using TypeScript.
**Why:** Strong typing improves code maintainability, prevents runtime errors, and enhances code completion in IDEs.
**Code Example:**
"""typescript
interface Product {
id: number;
name: string;
price: number;
}
type ProductAction =
| { type: "ADD_PRODUCT"; payload: Product }
| { type: "REMOVE_PRODUCT"; payload: number };
interface ProductState {
products: Product[];
}
"""
### 4.2. Performance Optimization
* **Do This:** Use memoization techniques (e.g., "useMemo", "useCallback", "React.memo") to prevent unnecessary re-renders. Select only the necessary data from the state to avoid triggering updates when unrelated data changes.
* **Don't Do This:** Neglect performance optimization, especially in large applications with frequent state updates. Deeply nested state often triggers expensive rerenders.
**Why:** Optimizing performance ensures a smooth user experience and reduces resource consumption. Memoization and selective updates can significantly improve rendering performance.
### 4.3. Testing
* **Do This:** Write unit tests for your reducers, selectors, and effects to ensure they behave as expected.
* **Don't Do This:** Neglect testing state management logic, which can lead to unexpected behavior and difficult-to-debug issues.
**Why:** Thorough testing is essential for maintaining the integrity of your state management system. Unit tests provide confidence that your code is working correctly and prevent regressions when making changes.
### 4.4. Error Handling
* **Do This:** Properly handle errors that may occur during state updates, asynchronous operations, or API calls. Display user-friendly error messages and provide mechanisms for recovering from errors.
* **Don't Do This:** Ignore errors or allow them to propagate silently, which can lead to a poor user experience and data corruption.
**Why:** Robust error handling ensures that your application can gracefully recover from unexpected errors and prevents data loss or corruption.
danielsogl
Created Mar 6, 2025
# Testing Methodologies Standards for TypeScript
This document outlines the standards for testing TypeScript code to ensure quality, reliability, and maintainability. It covers unit, integration, and end-to-end testing strategies, with a focus on modern approaches and patterns in the TypeScript ecosystem.
## 1. General Testing Principles
These principles apply to all types of testing and are essential for creating a robust and reliable codebase.
### 1.1. Write Tests That Are Independent and Repeatable
* **DO THIS:** Ensure that tests can be run in any order without affecting each other. Use mock data and isolated environments to avoid external dependencies.
* **DON'T DO THIS:** Rely on shared mutable state between tests. This leads to flaky tests that pass or fail unpredictably.
**WHY:** Independent tests provide consistent results, making debugging easier.
"""typescript
// DO THIS: Mock dependencies
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { User } from './user.model';
jest.mock('./user.repository');
describe('UserService', () => {
let userService: UserService;
let userRepositoryMock: jest.Mocked<UserRepository>;
beforeEach(() => {
userRepositoryMock = {
getUserById: jest.fn(),
saveUser: jest.fn(),
} as jest.Mocked<UserRepository>;
userService = new UserService(userRepositoryMock);
});
it('should get a user by ID', async () => {
const mockUser: User = { id: 1, name: 'Test User' };
userRepositoryMock.getUserById.mockResolvedValue(mockUser);
const user = await userService.getUserById(1);
expect(user).toEqual(mockUser);
expect(userRepositoryMock.getUserById).toHaveBeenCalledWith(1);
});
});
// DON'T DO THIS: Rely on database state
// Bad example: Test modifies a database record used by another test
"""
### 1.2. Write Tests That Are Fast
* **DO THIS:** Optimize tests to run quickly by avoiding unnecessary I/O operations or complex computations.
* **DON'T DO THIS:** Perform slow operations like initializing databases or making external API calls in unit tests. Use integration tests and end-to-end tests for these scenarios.
**WHY:** Fast tests encourage frequent test runs, reducing the time to catch and fix bugs.
"""typescript
// DO THIS: Use in-memory data structures for unit tests
import { calculateSum } from './math.utils';
describe('calculateSum', () => {
it('should calculate the sum of two numbers', () => {
expect(calculateSum(2, 3)).toBe(5);
});
});
// DON'T DO THIS: Complex setup in each test case
"""
### 1.3. Aim for High Code Coverage
* **DO THIS:** Strive to cover as much of your codebase as possible with tests, aiming for over 80% coverage. Use code coverage tools to identify gaps in your test suite.
* **DON'T DO THIS:** Equate high coverage with quality. Ensure tests assert the correct behavior and handle edge cases.
**WHY:** High code coverage reduces the risk of undetected bugs.
### 1.4. Use Meaningful Assertions
* **DO THIS:** Write assertions that clearly specify the expected outcome of the test. Use descriptive messages to make debugging easier.
* **DON'T DO THIS:** Use generic assertions that don't provide specific information about the failure.
**WHY:** Clear assertions help pinpoint the cause of a test failure quickly.
"""typescript
// DO THIS: Specific assertion
test('should return the correct greeting message', () => {
const result = greet('Alice');
expect(result).toBe('Hello, Alice!');
});
// DON'T DO THIS: Vague assertion
test('should return a string', () => {
const result = greet('Alice');
expect(typeof result).toBe('string'); // Not very specific
});
"""
### 1.5 Follow the Arrange-Act-Assert (AAA) Pattern
* **DO THIS:** Structure your tests to have a clear "Arrange", "Act", and "Assert" sections.
* **DON'T DO THIS:** Mix the stages, making the test hard to reason about.
**WHY:** Makes the test logic more understandable and easier to maintain.
"""typescript
describe('User Authentication', () => {
it('should authenticate a user with valid credentials', async () => {
// Arrange
const username = 'testuser';
const password = 'password123';
const userRepository = {
findUserByUsername: jest.fn().mockResolvedValue({ username, password }),
};
const authService = new AuthService(userRepository as any);
// Act
const result = await authService.authenticate(username, password);
// Assert
expect(result).toBe(true);
});
});
"""
## 2. Unit Testing
Unit tests focus on testing individual units (e.g., functions, classes) in isolation.
### 2.1. Use Mocking to Isolate Units
* **DO THIS:** Use mocking libraries like Jest or Sinon to simulate the behavior of dependencies.
* **DON'T DO THIS:** Directly use real dependencies in unit tests, as this couples the test to external systems.
**WHY:** Mocking simplifies the test setup and makes the test execution predictable.
"""typescript
// DO THIS: Mocking dependencies
import { EmailService } from './email.service';
import { sendWelcomeEmail } from './user.utils';
jest.mock('./email.service');
describe('sendWelcomeEmail', () => {
it('should send a welcome email to the user', async () => {
const mockEmailService = EmailService as jest.Mocked<typeof EmailService>;
mockEmailService.sendEmail.mockResolvedValue(undefined);
await sendWelcomeEmail('test@example.com', 'Test User');
expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
'test@example.com',
'Welcome',
'Welcome, Test User!'
);
});
});
"""
### 2.2. Test Boundary Conditions and Edge Cases
* **DO THIS:** Include tests that cover boundary conditions, such as empty inputs, maximum values, or invalid inputs.
* **DON'T DO THIS:** Only test the happy path. This increases the risk of bugs in production.
**WHY:** Testing edge cases ensures your code handles unexpected inputs gracefully.
"""typescript
// DO THIS: Test edge cases
import { divide } from './math.utils';
describe('divide', () => {
it('should divide two numbers correctly', () => {
expect(divide(10, 2)).toBe(5);
});
it('should throw an error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrowError('Cannot divide by zero');
});
it('should return 0 when dividing zero by a number', () => {
expect(divide(0, 5)).toBe(0);
});
});
"""
### 2.3. Leverage TypeScript's Type System in Tests
* **DO THIS:** Use TypeScript's type system to write type-safe tests. Define types for mock data and expected results.
* **DON'T DO THIS:** Use "any" or "unknown" excessively in tests, as this defeats the purpose of using TypeScript.
**WHY:** Type-safe tests catch type-related errors early.
"""typescript
// DO THIS: Use types in tests
import { User, createUser } from './user.model';
describe('createUser', () => {
it('should create a user with the given name and email', () => {
const userData: Omit<User, 'id'> = {
name: 'Test User',
email: 'test@example.com',
};
const user: User = createUser(userData);
expect(user.name).toBe(userData.name);
expect(user.email).toBe(userData.email);
expect(user.id).toBeDefined();
});
});
"""
### 2.4. Test Private Methods (Use with Caution)
* **DO THIS:** Avoid testing private methods directly, but if necessary, use techniques like accessing them through bracket notation or making them protected and creating a derived class for testing.
* **DON'T DO THIS:** Refactor code solely to make private methods testable. Consider the design implications.
**WHY:** Testing private methods can tightly couple tests to implementation details, making refactoring difficult, but sometimes testing them is crucial to ensure functionality.
"""typescript
// Testing private methods, a rare case where it's justified
class MyClass {
private add(a: number, b: number): number{
return a + b;
}
public performOperation(a: number, b:number): number {
return this.add(a,b) * 2;
}
}
describe('MyClass', () => {
it('should add two numbers correctly (testing private method)', () => {
const myClass = new MyClass();
// Accessing private method using bracket notation (use sparingly)
const result = (myClass as any).add(5, 3);
expect(result).toBe(8);
});
it('should perform the operation', ()=>{
const myClass = new MyClass();
const result = myClass.performOperation(5,3);
expect(result).toBe(16);
});
});
"""
## 3. Integration Testing
Integration tests verify the interaction between different parts of a system or application.
### 3.1. Choose Appropriate Scenarios
* **DO THIS:** Identify critical interactions between modules and write tests to verify these interactions.
* **DON'T DO THIS:** Attempt to test every possible interaction. Focus on essential scenarios that impact the system's overall behavior.
**WHY:** Integration tests ensure different parts of the system work together as expected.
### 3.2. Use Test Databases or Mocked External Services
* **DO THIS:** Set up a dedicated test database or use mocked external services to avoid affecting production data.
* **DON'T DO THIS:** Run integration tests against production databases. This can lead to data corruption or unintended side effects.
**WHY:** Isolated environments ensure predictable test results. Setting up a test database with seeding can be achieved using tools like Docker and custom scripts.
"""typescript
// Example showing how to setup an integration test using testcontainers
// Dockerfile (simplified)
// FROM postgres:latest
// ENV POSTGRES_USER=test
// ENV POSTGRES_PASSWORD=test
// ENV POSTGRES_DB=testdb
// Jest test
import { PostgreSqlContainer } from 'testcontainers';
import { DataSource } from 'typeorm';
describe('Database Integration Test', () => {
let container: PostgreSqlContainer;
let dataSource: DataSource;
beforeAll(async () => {
container = await new PostgreSqlContainer()
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
dataSource = new DataSource({
type: 'postgres',
host: container.getHost(),
port: container.getMappedPort(5432),
username: 'test',
password: 'test',
database: 'testdb',
entities: [], // Your entities
synchronize: true, // Auto-create schema for testing
});
await dataSource.initialize();
});
afterAll(async () => {
await dataSource.destroy();
await container.stop();
});
it('should be able to connect to the database', async () => {
expect(dataSource.isInitialized).toBe(true);
// Now you can perform actual database operations using dataSource
// And assert the expected result
});
});
"""
### 3.3. Verify Data Consistency
* **DO THIS:** Check that data is correctly persisted and retrieved from databases or external systems.
* **DON'T DO THIS:** Only verify the immediate result of an interaction. Ensure that the data remains consistent over time.
**WHY:** Data consistency is crucial for maintaining the integrity of the system.
"""typescript
// DO THIS: Verify data consistency
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { User } from './user.model';
describe('UserService Integration', () => {
let userService: UserService;
let userRepository: UserRepository;
beforeEach(() => {
userRepository = new UserRepository(); // Assuming it connects to a test DB
userService = new UserService(userRepository); //Real UserRepository, test DB.
});
it('should create and retrieve a user', async () => {
const userData: Omit<User, 'id'> = { name: 'Test User', email: 'test@example.com' };
const createdUser = await userService.createUser(userData);
const retrievedUser = await userService.getUserById(createdUser.id);
expect(retrievedUser).toEqual(createdUser); //Check they are the same
});
});
"""
### 3.4 Handling Asynchronous Operations in Integration Tests
* **DO THIS:** Use "async/await" or Promises to correctly handle asynchronous operations when testing integrations that involve databases, external APIs, or message queues.
* **DON'T DO THIS:** Forget to await asynchronous calls or fail to handle rejections, which can lead to incomplete tests or unhandled errors.
**WHY:** Prevents flaky and unreliable tests by correctly awaiting the completion of async tasks.
"""typescript
// Example: Integration test with asynchronous database retrieval
it('should retrieve a user asynchronously', async () => {
const userId = 123;
const mockUser = { id: userId, name: 'Async User' };
// Mock the userRepository's getUserById method to simulate async retrieval
userRepositoryMock.getUserById.mockResolvedValue(mockUser);
// Act: Call the service method that retrieves the user
const retrievedUser = await userService.getUserById(userId);
// Assert: Verify that the retrieved user matches the expected user
expect(retrievedUser).toEqual(mockUser);
expect(userRepositoryMock.getUserById).toHaveBeenCalledWith(userId);
});
"""
## 4. End-to-End (E2E) Testing
E2E tests simulate real user scenarios by testing the entire application from start to finish.
### 4.1. Use a Testing Framework
* **DO THIS:** Use a testing framework like Cypress, Playwright, or Puppeteer to automate browser interactions.
* **DON'T DO THIS:** Manually test the application. This is error-prone and time-consuming.
**WHY:** Automation reduces the cost of E2E testing and improves test coverage.
### 4.2. Focus on Key User Flows
* **DO THIS:** Identify critical user flows, such as login, registration, or checkout, and write E2E tests to verify they work correctly.
* **DON'T DO THIS:** Attempt to test every possible user interaction. Prioritize the most important flows.
**WHY:** Focusing on key flows ensures the application is usable for the majority of users.
### 4.3. Use Realistic Test Data
* **DO THIS:** Use realistic test data to simulate real-world scenarios. This includes user accounts, products, and orders.
* **DON'T DO THIS:** Use unrealistic or incomplete data, as this can lead to false positives or negatives.
**WHY:** Realistic data ensures that tests accurately reflect the application's behavior in production. Consider using a seeding strategy to populate test data.
### 4.4. Clean Up After Tests
* **DO THIS:** Clean up any data created during the test execution, such as deleting user accounts or resetting the database.
* **DON'T DO THIS:** Leave the application in an inconsistent state after the tests have finished.
**WHY:** Cleaning up ensures that subsequent tests are not affected by previous runs.
"""typescript
// Example Cypress test (Cypress uses JavaScript/TypeScript)
describe('User Registration', () => {
it('should register a new user successfully', () => {
cy.visit('/register');
cy.get('#name').type('Test User');
cy.get('#email').type('test@example.com');
cy.get('#password').type('password123');
cy.get('#confirmPassword').type('password123');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome, Test User!').should('be.visible');
});
afterEach(() => {
// Clean up by deleting the user account
cy.request('DELETE', '/api/users/test@example.com'); //Assuming a DELETE API
});
});
"""
## 5. Testing Tools and Libraries
* **Jest:** A popular testing framework with built-in mocking and assertion capabilities.
* **Mocha:** A flexible testing framework that can be combined with assertion libraries like Chai and mocking libraries like Sinon.
* **Cypress:** An E2E testing framework specifically designed for web applications.
* **Playwright:** A framework for reliable end-to-end testing for web apps. Supports Chromium, Firefox and Webkit
* **Puppeteer:** A Node library that provides a high-level API to control Chrome or Chromium.
* **Testcontainers:** A library that provides lightweight, throwaway instances of databases, message brokers, and more. Ideal for integration testing.
* **Supertest:** A library for testing HTTP APIs.
## 6. Continuous Integration (CI)
* **DO THIS:** Integrate your tests into a CI/CD pipeline to automatically run tests on every code change.
* **DON'T DO THIS:** Rely on manual test runs.
* **WHY:** Automating tests catches bugs early and prevents them from reaching production.
## 7. Testing React specific components with TypeScript
Testing React components that are written in TypeScript introduces a few nuances.
### 7.1 Use Testing Library
* **DO THIS**: Use React Testing Library instead of Enzyme. React Testing Library encourages testing components from a user's perspective.
* **DON'T DO THIS**: Write tests that rely on implementation details.
"""typescript
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MyComponent from './MyComponent';
describe('MyComponent', () => {
it('should display the correct message', () => {
render(<MyComponent message="Hello, World!" />);
expect(screen.getByText('Hello, World!')).toBeInTheDocument();
});
it('should call the onClick handler when the button is clicked', async () => {
const handleClick = jest.fn();
render(<MyComponent message="Click Me!" onClick={handleClick} />);
await userEvent.click(screen.getByText('Click Me!'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
// DON'T DO THIS - Testing implementation details.
// This test will break if you change the className of the element.
it('should have the correct class name', () => {
render(<MyComponent message="Class Name Test" />);
expect(screen.getByText('Class Name Test')).toHaveClass('my-component');
});
"""
### 7.2 Mocking Modules (Properly)
* **DO THIS**: When your React component imports external modules, mock them appropriately.
* **DON'T DO THIS**: Directly import and use real modules in your tests, resulting in integration tests rather than isolated unit tests.
"""typescript
jest.mock('./api', () => ({ // Mock the entire module
fetchData: jest.fn(() => Promise.resolve({ data: 'mocked data' })), }));
it('fetches data correctly', async () => { const { findByText } = render(<MyComponent />); expect(await findByText('mocked data')).toBeInTheDocument();});
"""
### 7.3 Testing Component State Changes
* **DO THIS:** Test state changes thoroughly, including initial state, state updates based on user interactions, and state-dependent rendering.
* **DON'T DO THIS:** Assume state updates correctly without verifying the changes in the rendered output.
"""typescript
import React, { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
test('increments the count when the button is clicked', () => {
render(<Counter />);
const button = screen.getByText('Increment');
const countDisplay = screen.getByText('Count: 0');
fireEvent.click(button);
expect(countDisplay).toHaveTextContent('Count: 1');
fireEvent.click(button);
expect(countDisplay).toHaveTextContent('Count: 2'); // Verify multiple increments
});
"""
This comprehensive document provides a solid foundation for establishing effective testing methodologies in TypeScript projects, ensuring code quality, reliability, and maintainability. Remember that this is a living document that should be updated as the TypeScript ecosystem evolves and new best practices emerge.
danielsogl
Created Mar 6, 2025
# API Integration Standards for TypeScript
This document outlines the standards and best practices for integrating with backend services and external APIs in TypeScript projects. Adhering to these guidelines will result in more maintainable, performant, and secure code.
## 1. Architectural Considerations
### 1.1. Abstraction and Decoupling
**Standard:** Decouple API interaction logic from core application logic using abstraction layers.
**Why:** This separates concerns, improves testability, reduces dependencies, and allows for easier switching of API implementations without affecting the rest of the application.
**Do This:**
"""typescript
// api-client.ts
export interface APIClient {
fetchData(endpoint: string): Promise<any>;
}
export class DefaultAPIClient implements APIClient {
async fetchData(endpoint: string): Promise<any> {
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
}
}
// service.ts
import { APIClient, DefaultAPIClient } from './api-client';
export class MyService {
private apiClient: APIClient;
constructor(apiClient: APIClient = new DefaultAPIClient()) {
this.apiClient = apiClient;
}
async getData(): Promise<any> {
return this.apiClient.fetchData('/api/data');
}
}
"""
**Don't Do This:**
"""typescript
// service.ts (Anti-pattern: Tightly coupled API call)
export class MyService {
async getData(): Promise<any> {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
}
}
"""
### 1.2. Centralized API Configuration
**Standard:** Store API endpoints, timeouts, and authentication details in a centralized configuration.
**Why:** Easier management of API configurations across the application, enabling quick updates and environment-specific configurations. Leveraging environment variables is crucial.
**Do This:**
"""typescript
// config.ts
export const API_CONFIG = {
baseURL: process.env.REACT_APP_API_BASE_URL || 'https://api.example.com',
timeout: 5000, // milliseconds
apiKey: process.env.REACT_APP_API_KEY,
};
// api-client.ts
import { API_CONFIG } from './config';
export class DefaultAPIClient {
async fetchData(endpoint: string): Promise<any> {
try {
const response = await fetch("${API_CONFIG.baseURL}${endpoint}", {
headers: {
'x-api-key': API_CONFIG.apiKey || ''
},
signal: AbortSignal.timeout(API_CONFIG.timeout)
});
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
} catch (error) {
console.error("API Fetch Error:", error);
throw error; // Re-throw to allow calling code to handle the error
}
}
}
"""
**Don't Do This:**
"""typescript
// api-client.ts (Anti-pattern: Hardcoded API endpoint)
export class DefaultAPIClient {
async fetchData(): Promise<any> {
const response = await fetch('https://api.example.com/api/data'); // Hardcoded URL
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
}
}
"""
### 1.3. Error Handling Strategy
**Standard:** Implement a consistent and centralized error handling mechanism for API calls. Use try/catch blocks and custom error classes.
**Why:** Provides a predictable way to handle API failures, improving debugging and enhancing user experience (e.g., showing appropriate error messages).
**Do This:**
"""typescript
// api-client.ts
export class APIError extends Error {
constructor(message: string, public statusCode: number) {
super(message);
this.name = 'APIError';
}
}
export class DefaultAPIClient {
async fetchData(endpoint: string): Promise<any> {
try {
const response = await fetch(endpoint);
if (!response.ok) {
throw new APIError("HTTP error! status: ${response.status}", response.status);
}
return await response.json();
} catch (error: any) { // Explicit 'any' type for error
// Log the error, potentially with more context
console.error('API call failed:', error);
throw error; // Re-throw to propagate the error
}
}
}
// service.ts
import { APIError } from './api-client';
export class MyService {
async getData(): Promise<any> {
try {
return await this.apiClient.fetchData('/api/data');
} catch (error) {
if (error instanceof APIError) {
console.error("API Error: ${error.message}, Status Code: ${error.statusCode}");
// Handle specific API errors (e.g., display user-friendly message)
} else {
console.error('Unexpected error:', error);
// Handle unexpected errors
}
throw error; // Re-throw or handle as needed
}
}
}
"""
**Don't Do This:**
"""typescript
// api-client.ts (Anti-pattern: Ignoring errors)
export class DefaultAPIClient {
async fetchData(endpoint: string): Promise<any> {
try {
const response = await fetch(endpoint);
return await response.json(); // No error checking, potential crash
} catch (error) {
console.error("Error fetching data"); //Logging, but program continues as if nothing happened
return null; //This is a very bad practice
}
}
}
"""
### 1.4. Data Transformation
**Standard:** Implement data transformation and mapping logic to adapt API responses to the application's data models.
**Why:** Decouples the application from API-specific data structures, improving flexibility and maintainability.
**Do This:**
"""typescript
// api-client.ts
export interface RawUserData {
id: number;
firstName: string;
lastName: string;
emailAddress: string;
}
// models.ts
export interface User {
id: number;
fullName: string;
email: string;
}
// api-client.ts
export class DefaultAPIClient {
async fetchData(endpoint: string): Promise<RawUserData[]> {
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
}
}
// service.ts
import {DefaultAPIClient, RawUserData} from './api-client';
import {User} from './models';
export class UserService {
constructor(private apiClient: DefaultAPIClient) {}
async getUsers(): Promise<User[]> {
const rawUsers: RawUserData[] = await this.apiClient.fetchData('/api/users');
return rawUsers.map(this.transformUser);
}
private transformUser(rawUser: RawUserData): User {
return {
id: rawUser.id,
fullName: "${rawUser.firstName} ${rawUser.lastName}",
email: rawUser.emailAddress,
};
}
}
"""
**Don't Do This:**
"""typescript
// service.ts (Anti-pattern: Direct use of API data structures)
interface RawUserData { //Defined inside the service; tight coupling
id: number;
firstName: string;
lastName: string;
emailAddress: string;
}
export class MyService {
async getData(): Promise<RawUserData[]> {
const response = await await fetch('/api/users');
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
}
}
"""
## 2. Implementation Details
### 2.1. Using "fetch" API
**Standard:** Use the native "fetch" API or a library built on top of it (e.g., "axios") for making HTTP requests. Configure request timeouts and handle abort signals.
**Why:** "fetch" is the standard API for making web requests, is supported natively in modern browsers/Node.js, and offers a cleaner interface compared to older methods like "XMLHttpRequest".
**Do This:**
"""typescript
// api-client.ts
import { API_CONFIG } from './config';
export class DefaultAPIClient {
async fetchData(endpoint: string): Promise<any> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.timeout);
try {
const response = await fetch("${API_CONFIG.baseURL}${endpoint}", {
signal: controller.signal, // Pass the abort signal
});
clearTimeout(timeoutId); // Clear timeout if request completes
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
} catch (error: any) {
clearTimeout(timeoutId); //clear timeout if abort happens
console.error('Fetch error:', error);
if (error.name === 'AbortError') {
console.log('Request aborted due to timeout.');
}
throw error;
}
}
}
"""
**Don't Do This:**
"""typescript
// api-client.ts (Anti-pattern: Using deprecated XMLHttpRequest)
export class DefaultAPIClient {
fetchData(endpoint: string): Promise<any> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', endpoint);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error("Request failed with status: ${xhr.status}"));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send();
});
}
}
"""
### 2.2. Data Serialization and Deserialization
**Standard:** Use "JSON.stringify" and "JSON.parse" for serializing and deserializing data when interacting with JSON APIs. Consider using libraries like "class-transformer" for complex object mapping.
**Why:** Ensures proper data formatting during API interactions. Advanced libraries provide features like type validation and custom transformation logic.
**Do This:**
"""typescript
// Example with JSON.stringify and JSON.parse
interface UserData {
name: string;
age: number;
}
async function postData(url: string, data: UserData): Promise<any> {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json(); // Assuming the response is also JSON
}
"""
For more complex cases, consider "class-transformer" (install with "npm install class-transformer class-validator --save")
"""typescript
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
class User {
id: number;
firstName: string;
lastName: string;
email: string;
}
async function fetchUser(url: string): Promise<User> {
const response = await fetch(url);
const data = await response.json();
const user = plainToClass(User, data);
const errors = await validate(user); //Needs decorators on the class
if (errors.length > 0) {
console.error("Validation failed:", errors);
throw new Error("User data validation failed");
}
return user;
}
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Manually constructing JSON strings (error-prone)
const data = { name: 'John', age: 30 };
const body = '{ "name": "' + data.name + '", "age": ' + data.age + ' }'; // Avoid this!
"""
### 2.3. Handling Authentication and Authorization
**Standard:** Implement secure authentication and authorization mechanisms (e.g., JWT, OAuth 2.0) for accessing protected APIs. Store credentials safely (e.g., using environment variables or secure storage). Use HTTPS for all API communications.
**Why:** Protects sensitive data and ensures that only authorized users/applications can access specific resources.
**Do This:**
"""typescript
// Example using JWT (JSON Web Token) in API client
import { API_CONFIG } from './config';
export class DefaultAPIClient {
private authToken: string | null = null;
setAuthToken(token: string): void {
this.authToken = token;
}
async fetchData(endpoint: string): Promise<any> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
// Add authentication token if available
};
if (this.authToken) {
headers['Authorization'] = "Bearer ${this.authToken}";
}
const response = await fetch("${API_CONFIG.baseURL}${endpoint}", {
headers,
});
if (!response.ok) {
if (response.status === 401) {
// Handle unauthorized error (e.g., redirect to login)
console.error("Unauthorized access, redirecting to login");
//Example: window.location.href = '/login';
}
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
}
}
//Example usage by service:
export class AuthService {
constructor(private apiClient: DefaultAPIClient){}
async login(credentials: Credentials): Promise<boolean> {
const response = await fetch('/api/login', {
method: "POST",
body: JSON.stringify(credentials)
});
const body = await response.json();
if(response.ok && body.token){
this.apiClient.setAuthToken(body.token);
return true;
}
return false;
}
}
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Storing API keys directly in the code (security risk)
const apiKey = 'YOUR_API_KEY'; //Never do this!
"""
### 2.4. Caching Strategies
**Standard:** Implement caching mechanisms to reduce the number of API calls and improve performance (e.g., using browser caching, service worker caching, or server-side caching).
**Why:** Reduces latency and improves responsiveness by serving data from cache instead of making frequent API requests.
**Do This (browser caching):**
"""typescript
// Setting cache headers on the server (Node.js example)
import express, { Request, Response } from 'express';
const app = express();
app.get('/api/data', (req: Request, res: Response) => {
// Set cache headers
res.set('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour
res.json({ data: 'Cached data' });
});
"""
**Do This (Service Worker caching):**
This example shows a simple caching strategy within a service worker to cache API responses. This service worker would need to be setup within your application.
"""typescript
//In service-worker.js:
const CACHE_NAME = 'api-cache-v1';
const API_URL_REGEX = /^\/api\//; // Regex for API endpoints
self.addEventListener('install', (event: any) => {
console.log("Installing service worker");
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
//Cache static assets (optional)
return cache.addAll(['/', '/index.html', '/styles.css', '/script.js']);
})
);
});
self.addEventListener('fetch', (event: any) => {
const url = new URL(event.request.url);
if (url.origin === location.origin && API_URL_REGEX.test(url.pathname)) {
event.respondWith(
caches.match(event.request).then(response => {
//Return cached response if available
if(response) {
console.log("Returning cached response for:", event.request.url);
return response;
}
//Otherwise fetch from network, cache, and return
return fetch(event.request).then(networkResponse => {
if(!networkResponse || networkResponse.status !==200 || networkResponse.type !== 'basic'){
return networkResponse; //Don't cache if not a "good" response
}
const responseToCache = networkResponse.clone(); //Clone as response body can only be read once
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseToCache);
});
return networkResponse;
});
})
);
}
});
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Never caching API responses (performance bottleneck)
"""
### 2.5. Rate Limiting
**Standard:** Implement rate limiting on the client-side to prevent overwhelming APIs with excessive requests.
**Why:** Avoids exceeding API usage limits and ensures fair access to resources, improving overall application stability.
**Do This:**
"""typescript
// Example using a basic rate limiter
class RateLimiter {
private calls: number = 0;
private lastReset: number = Date.now();
private maxCalls: number = 10;
private resetInterval: number = 60000; // 1 minute
canMakeCall(): boolean {
const now = Date.now();
if (now - this.lastReset > this.resetInterval) {
this.calls = 0;
this.lastReset = now;
}
return this.calls < this.maxCalls;
}
recordCall(): void {
this.calls++;
}
}
const limiter = new RateLimiter();
async function fetchDataWithRateLimit(url: string): Promise<any> {
if (limiter.canMakeCall()) {
limiter.recordCall();
const response = await fetch(url);
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
} else {
throw new Error('Rate limit exceeded. Please try again later.');
}
}
// Usage
fetchDataWithRateLimit('/api/data')
.then(data => console.log(data))
.catch(error => console.error(error));
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Making API calls in rapid succession without any rate limiting
"""
## 3. Asynchronous Operations
### 3.1. Using "async/await"
**Standard:** Use "async/await" syntax for handling asynchronous operations (e.g., API calls).
**Why:** Provides a more readable and maintainable way to write asynchronous code compared to callbacks or promises.
**Do This:**
"""typescript
async function fetchData(): Promise<any> {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error("HTTP error! status: ${response.status}");
}
return await response.json();
} catch (error: any) {
console.error('Fetch error:', error);
throw error; // Re-throw for handling in the calling function
}
}
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Using nested callbacks (callback hell)
function fetchData(callback: (data: any) => void) {
fetch('/api/data')
.then(response => response.json())
.then(data => callback(data))
.catch(error => console.error(error));
}
"""
### 3.2. Parallel API Calls
**Standard:** Use "Promise.all" or "Promise.allSettled" to make multiple API calls in parallel when appropriate.
**Why:** Improves performance by reducing the overall time required to fetch data from multiple sources.
**Do This:**
"""typescript
async function fetchMultipleData(): Promise<any[]> {
try {
const [data1, data2] = await Promise.all([
fetch('/api/data1').then(response => response.json()),
fetch('/api/data2').then(response => response.json()),
]);
return [data1, data2];
} catch (error: any) {
console.error('Error fetching data:', error);
return []; // Or handle error appropriately
}
}
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Making API calls sequentially (slower performance)
async function fetchMultipleData(): Promise<any[]> {
const data1 = await fetch('/api/data1').then(response => response.json());
const data2 = await fetch('/api/data2').then(response => response.json());
return [data1, data2];
}
"""
## 4. Data Validation
### 4.1. Input Validation
**Standard:** Validate user inputs and API request parameters to prevent injection attacks and ensure data integrity. Use libraries like "zod" or "yup" for schema validation.
**Why:** Enhances security by preventing malicious data from being sent to the API and ensures that the application receives valid data.
**Do This (using Zod):**
First, install Zod: "npm install zod"
"""typescript
import { z } from 'zod';
const UserSchema = z.object({
id: z.number().positive(),
username: z.string().min(3).max(20),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function createUser(userData: unknown): Promise<User> {
try {
const parsedData = UserSchema.parse(userData);
// Send parsedData to the API
console.log('Valid user data:', parsedData);
return parsedData;
} catch (error: any) {
console.error('Validation error:', error.errors);
throw new Error('Invalid user data');
}
}
// Usage
const validUserData = {
id: 1,
username: 'johndoe',
email: 'john.doe@example.com',
};
const invalidUserData = {
id: -1,
username: 'jd',
email: 'invalid-email',
};
createUser(validUserData)
.then(user => console.log('Created user:', user))
.catch(error => console.error('Failed to create user:', error.message));
createUser(invalidUserData)
.catch(error => console.error('Failed to create user:', error.message));
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Directly passing user input to the API without validation
async function createUser(username: string, email: string): Promise<any> {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify({ username, email }), //No validation!
});
return response.json();
}
"""
### 4.2. Response Validation
**Standard:** Validate API responses to ensure data consistency and handle unexpected data formats. Use TypeScript interfaces/types and runtime validation (e.g., with "zod") to ensure data conforms to expected schemas.
**Why:** Enhances robustness by preventing errors caused by malformed or unexpected API responses.
**Do This**:
"""typescript
import { z } from 'zod';
const ApiResponseSchema = z.object({
status: z.string(),
data: z.array(z.object({
id: z.number(),
name: z.string()
}))
});
type ApiResponse = z.infer<typeof ApiResponseSchema>;
async function fetchData(url: string) {
const response = await fetch(url);
const data = await response.json();
try {
const validatedData = ApiResponseSchema.parse(data);
console.log("Validated data:", validatedData);
return validatedData;
} catch (error) {
console.error("Response validation failed:", error);
throw error;
}
}
"""
**Don't Do This:**
"""typescript
// Anti-pattern: Assuming API responses are always in the expected format
async function fetchData(): Promise<any> {
const response = await fetch('/api/data');
const data = await response.json();
return data.items; // Assuming data.items always exists and is an array!
}
"""
## 5. Testing
### 5.1. Unit Testing
**Standard:** Write unit tests for API client and service classes to ensure they function correctly. Mock API dependencies using libraries like "jest" or "sinon".
**Why:** Improves code quality by verifying functionality and preventing regressions during refactoring.
**Do This:**
"""typescript
// api-client.test.ts
import { DefaultAPIClient } from './api-client';
describe('DefaultAPIClient', () => {
it('should fetch data successfully', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: 'test data' }),
});
global.fetch = mockFetch; // Mock the global fetch
const apiClient = new DefaultAPIClient();
const data = await apiClient.fetchData('/api/data');
expect(mockFetch).toHaveBeenCalledWith('/api/data');
expect(data).toEqual({ data: 'test data' });
});
it('should handle errors when fetching data', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: false,
status: 500,
});
global.fetch = mockFetch;
const apiClient = new DefaultAPIClient();
await expect(apiClient.fetchData('/api/data')).rejects.toThrow('HTTP error! status: 500');
});
});
"""
**Don't Do This:**
"""typescript
// Anti-pattern: No unit tests for API interaction logic
"""
### 5.2. Integration Testing
**Standard:** Write integration tests to verify the interaction between different components and the API.
**Why:** Ensures that components work together correctly and the API integration functions as expected in a real-world scenario. These would likely utilize live dockerized APIs or mock servers running locally.
**Do This:**
The specific style would vary heavily depending on project setup so a simple example is not feasible in this document.
**Don't Do This:**
"""typescript
// Anti-pattern: Skipping integration tests and relying solely on unit tests
"""
By adhering to these standards, TypeScript developers can create robust, efficient, and maintainable API integrations. This document serves as a guideline for best practices, promoting consistency and quality across projects.
danielsogl
Created Mar 6, 2025