Skip to main content

Command Palette

Search for a command to run...

SOLID Principles with JavaScript and TypeScript Functions

Updated
8 min read
SOLID Principles with JavaScript and TypeScript Functions

The SOLID principles are five fundamental design principles in object-oriented programming that help create more maintainable, flexible, and scalable code. Let's explore each principle with examples in both JavaScript and TypeScript.

1. Single Responsibility Principle (SRP)

A function should do one thing and do it well.

JavaScript Example:

// Violating SRP - this function does too many things
function processUser(userData) {
  // Validate user data
  if (!userData.name || !userData.email) {
    throw new Error('Invalid user data');
  }

  // Save to database
  console.log(`Saving user ${userData.name} to database`);

  // Send welcome email
  console.log(`Sending welcome email to ${userData.email}`);

  return { success: true };
}

// Following SRP - each function has one responsibility
function validateUser(userData) {
  if (!userData.name || !userData.email) {
    throw new Error('Invalid user data');
  }
  return userData;
}

function saveUser(userData) {
  console.log(`Saving user ${userData.name} to database`);
  return userData;
}

function notifyUser(userData) {
  console.log(`Sending welcome email to ${userData.email}`);
  return userData;
}

// We can compose these functions when needed
function processUser(userData) {
  return notifyUser(saveUser(validateUser(userData)));
}

TypeScript Example:

interface UserData {
  name: string;
  email: string;
}

// Each function has a clear, single purpose
function validateUser(userData: UserData): UserData {
  if (!userData.name || !userData.email) {
    throw new Error('Invalid user data');
  }
  return userData;
}

function saveUser(userData: UserData): UserData {
  console.log(`Saving user ${userData.name} to database`);
  return userData;
}

function notifyUser(userData: UserData): UserData {
  console.log(`Sending welcome email to ${userData.email}`);
  return userData;
}

// These can be combined using function composition
const processUser = (userData: UserData): UserData => 
  notifyUser(saveUser(validateUser(userData)));

The SRP for functions means each function should have exactly one reason to change. In the improved example, if we need to modify how validation works, we only need to change the validateUser function without touching the other functions.

2. Open/Closed Principle (OCP)

Functions should be open for extension but closed for modification.

JavaScript Example:

// Violating OCP - we'd need to modify this function to add new shapes
function calculateArea(shape) {
  if (shape.type === 'rectangle') {
    return shape.width * shape.height;
  } else if (shape.type === 'circle') {
    return Math.PI * shape.radius * shape.radius;
  }
  // Need to modify this function for each new shape
}

// Following OCP - using a strategy pattern with functions
const areaStrategies = {
  rectangle: (shape) => shape.width * shape.height,
  circle: (shape) => Math.PI * shape.radius * shape.radius,
  // We can add new shapes by adding new functions here
  triangle: (shape) => (shape.base * shape.height) / 2
};

function calculateArea(shape) {
  if (!areaStrategies[shape.type]) {
    throw new Error(`Shape type ${shape.type} not supported`);
  }
  return areaStrategies[shape.type](shape);
}

// We can extend by adding new strategies without modifying calculateArea
areaStrategies['trapezoid'] = (shape) => 
  (shape.base1 + shape.base2) * shape.height / 2;

The OCP for functions means we can add new functionality without modifying existing functions. In the examples above, we can add support for new shapes without changing the calculateArea function.

3. Liskov Substitution Principle (LSP)

For functions, this means that if a function accepts a certain type of argument, any subtype of that argument should work correctly as well.

JavaScript Example:

// Parent type function
function printDetails(animal) {
  return `This animal makes a sound: ${animal.makeSound()}`;
}

// These adhere to the LSP
const dog = {
  makeSound: () => "Woof"
};

const cat = {
  makeSound: () => "Meow"
};

// LSP violation - this breaks the contract
const fish = {
  swim: () => "Swimming..."
  // Missing makeSound method
};

// Following LSP - ensuring consistent behavior
function makeSound(animal) {
  // Check if the method exists
  if (typeof animal.makeSound !== 'function') {
    throw new TypeError('Animal must have makeSound method');
  }
  return animal.makeSound();
}

TypeScript Example:

// Using interfaces to enforce the contract
interface Soundable {
  makeSound(): string;
}

// Function expecting a Soundable
function getAnimalSound(animal: Soundable): string {
  return animal.makeSound();
}

// These implementations satisfy the interface
const dog: Soundable = {
  makeSound: () => "Woof"
};

const cat: Soundable = {
  makeSound: () => "Meow"
};

// Using function types as substitutes
type SoundFunction = () => string;

function makeSound(soundFn: SoundFunction): string {
  return soundFn();
}

// These functions are substitutable
const barkSound: SoundFunction = () => "Woof";
const meowSound: SoundFunction = () => "Meow";

console.log(makeSound(barkSound)); // "Woof"
console.log(makeSound(meowSound)); // "Meow"

The LSP for functions means any function that fulfills the expected contract should be able to replace another function with the same contract without breaking the program.

4. Interface Segregation Principle (ISP)

Function parameters should be minimal, and callers should not be forced to provide parameters they don't care about.

JavaScript Example:

// Violating ISP - too many parameters that might not be used
function createUser(name, email, address, phoneNumber, birthDate, subscription) {
  // Logic to create user
  console.log(`Creating user ${name} with email ${email}`);
  if (address) console.log(`Address: ${address}`);
  if (phoneNumber) console.log(`Phone: ${phoneNumber}`);
  // and so on...
}

// Following ISP - using object parameters with defaults
function createUser({ name, email, ...options } = {}) {
  if (!name || !email) throw new Error('Name and email are required');

  console.log(`Creating user ${name} with email ${email}`);

  if (options.address) console.log(`Address: ${options.address}`);
  if (options.phoneNumber) console.log(`Phone: ${options.phoneNumber}`);
  // Optional parameters don't clutter the interface
}

// Or breaking into more focused functions
function createBasicUser(name, email) {
  console.log(`Creating user ${name} with email ${email}`);
  return { name, email };
}

function addUserAddress(user, address) {
  console.log(`Adding address to user ${user.name}`);
  return { ...user, address };
}

function addUserPhone(user, phoneNumber) {
  console.log(`Adding phone number to user ${user.name}`);
  return { ...user, phoneNumber };
}

TypeScript Example:

// Using optional parameters and interfaces
interface UserRequired {
  name: string;
  email: string;
}

interface UserOptional {
  address?: string;
  phone?: string;
  birthDate?: Date;
  preferences?: Record<string, any>;
}

type User = UserRequired & UserOptional;

// Function only requires what it needs
function createUser(required: UserRequired, optional: Partial<UserOptional> = {}): User {
  return { ...required, ...optional };
}

// Functions focused on specific operations
function validateEmail(email: string): boolean {
  return email.includes('@');
}

function formatUserAddress(address: string): string {
  return address.toUpperCase();
}

// Usage examples
const user = createUser({ 
  name: 'John', 
  email: 'john@example.com' 
});

const userWithDetails = createUser(
  { name: 'Alice', email: 'alice@example.com' },
  { address: '123 Main St', phone: '555-1234' }
);

The ISP for functions means we should create smaller, more focused functions that only require the parameters they actually need, making them easier to use and test.

5. Dependency Inversion Principle (DIP)

High-level functions should not depend on low-level functions. Both should depend on abstractions.

JavaScript Example:

// Violating DIP - direct dependency on low-level function
function saveUserToMySQL(user) {
  console.log(`Saving ${user.name} to MySQL database`);
  // MySQL-specific code here
}

function createUser(userData) {
  const user = { name: userData.name, email: userData.email };
  saveUserToMySQL(user); // Direct dependency on MySQL implementation
  return user;
}

// Following DIP - passing the dependency as a function parameter
function createUser(userData, saveFunction) {
  const user = { name: userData.name, email: userData.email };
  saveFunction(user); // Depends on abstraction, not implementation
  return user;
}

// Different implementations that can be injected
function saveUserToMySQL(user) {
  console.log(`Saving ${user.name} to MySQL database`);
}

function saveUserToMongoDB(user) {
  console.log(`Saving ${user.name} to MongoDB`);
}

// Usage
createUser({ name: 'John', email: 'john@example.com' }, saveUserToMySQL);
createUser({ name: 'Jane', email: 'jane@example.com' }, saveUserToMongoDB);

TypeScript Example:

// Using function types as abstractions
interface User {
  name: string;
  email: string;
}

// Function type definition (the abstraction)
type SaveUserFn = (user: User) => Promise<User>;

// High-level function depends on abstraction
async function createUser(
  userData: Partial<User>, 
  saveUser: SaveUserFn
): Promise<User> {
  if (!userData.name || !userData.email) {
    throw new Error('Name and email are required');
  }

  const user: User = { 
    name: userData.name, 
    email: userData.email 
  };

  return await saveUser(user);
}

// Low-level implementations
const saveToMySQL: SaveUserFn = async (user: User): Promise<User> => {
  console.log(`Saving ${user.name} to MySQL database`);
  // MySQL-specific code would go here
  return user;
};

const saveToMongoDB: SaveUserFn = async (user: User): Promise<User> => {
  console.log(`Saving ${user.name} to MongoDB`);
  // MongoDB-specific code would go here
  return user;
};

// We can easily inject different dependencies
createUser({ name: 'John', email: 'john@example.com' }, saveToMySQL);
createUser({ name: 'Jane', email: 'jane@example.com' }, saveToMongoDB);

The DIP for functions means high-level functions should take adapter functions as parameters rather than importing and using low-level functions directly. This dependency injection allows for more flexible, testable code.

Practical Applications in Functional JavaScript/TypeScript

These principles are particularly relevant in functional programming approaches:

  1. Single Responsibility: Pure functions that do one thing well

  2. Open/Closed: Function composition and higher-order functions to extend behavior

  3. Liskov Substitution: Function types and consistent interfaces for callbacks

  4. Interface Segregation: Partial application and currying to create more focused function interfaces

  5. Dependency Inversion: Dependency injection through function parameters

Example of All Principles Together in Functional Style:

// User types
interface UserData {
  name: string;
  email: string;
  address?: string;
}

// Database abstraction (for DIP)
type DatabaseFn = (data: any) => Promise<any>;

// Notification abstraction (for DIP)
type NotifyFn = (user: UserData) => Promise<void>;

// Single Responsibility functions
const validateUserData = (data: Partial<UserData>): UserData => {
  if (!data.name || !data.name.trim()) {
    throw new Error('Name is required');
  }
  if (!data.email || !data.email.includes('@')) {
    throw new Error('Valid email is required');
  }

  return data as UserData;
};

// Open for extension with different database implementations
const createSaveUserFn = (db: DatabaseFn) => {
  return async (userData: UserData): Promise<UserData> => {
    await db(userData);
    return userData;
  };
};

// Open for extension with different notification strategies
const createNotifyUserFn = (notifier: NotifyFn) => {
  return async (userData: UserData): Promise<UserData> => {
    await notifier(userData);
    return userData;
  };
};

// High-level function that's closed for modification but depends on abstractions
const processUser = async (
  userData: Partial<UserData>,
  saveUser: (user: UserData) => Promise<UserData>,
  notifyUser: (user: UserData) => Promise<UserData>
): Promise<UserData> => {
  // Pipeline of operations
  const validatedUser = validateUserData(userData);
  const savedUser = await saveUser(validatedUser);
  return await notifyUser(savedUser);
};

// Concrete implementations
const saveToDatabase: DatabaseFn = async (data) => {
  console.log(`Saving to database: ${JSON.stringify(data)}`);
  return data;
};

const sendWelcomeEmail: NotifyFn = async (user) => {
  console.log(`Sending welcome email to ${user.email}`);
};

// Compose the full functionality
const saveUser = createSaveUserFn(saveToDatabase);
const notifyUser = createNotifyUserFn(sendWelcomeEmail);

// Usage
processUser(
  { name: 'John Doe', email: 'john@example.com' }, 
  saveUser, 
  notifyUser
).then(user => {
  console.log(`User processing complete: ${user.name}`);
});

This functional approach leverages all SOLID principles:

  • Each function has a single responsibility

  • We extend functionality by creating new functions, not modifying existing ones

  • Functions with the same signature are substitutable

  • Function parameters are minimal and focused

  • High-level functions depend on abstractions passed as parameters

By applying SOLID principles to functions, you can create more maintainable, flexible, and testable code in JavaScript and TypeScript, particularly in functional programming paradigms or in modern frameworks that emphasize component composition.

Javascript

Part 3 of 24

In this series I will be sharing my journey in learning Javascript and posting my own notes from the process. These notes can be helpful for beginners in learning this amazing language. Good luck.

Up next

Mastering the JavaScript delete Operator

Introduction In the world of JavaScript, think of the delete operator as your tool for making changes to objects and arrays. It allows you to remove specific things like properties in objects and elements in arrays. Let's explore how it works and wha...

More from this blog

Dipankar Paul's blog

51 posts