How to Write Clean JavaScript Objects and Data Structures: 6 Essential Principles
Encapsulate state using getters and setters, hide internal fields with closures, avoid repeating object names in properties, and never mutate shared references without cloning.
The ryanmcdermott/clean-code-javascript repository defines industry-standard conventions for writing maintainable JavaScript, with specific rules governing how you should structure objects and data structures. Writing clean JavaScript objects and data structures requires balancing encapsulation, immutability, and readability to prevent tight coupling and hidden side effects. These principles, documented in the repository's README.md, help you build data models that are easy to refactor and safe to consume across your application.
Remove Redundant Context from Property Names
Avoid repeating the object's name in its property names. This noise obscures the actual data shape and makes the API feel verbose.
According to the ryanmcdermott/clean-code-javascript guide in README.md, redundant prefixes like carMake or carColor add unnecessary boilerplate when the context is already clear from the object itself.
// ❌ Bad – repeats the object name
const Car = {
carMake: 'Honda',
carModel: 'Accord',
carColor: 'Blue'
};
// ✅ Good – concise and clear
const Car = {
make: 'Honda',
model: 'Accord',
color: 'Blue'
};
Encapsulate Access with Getters and Setters
Prefer getters and setters over direct property access. This creates a single chokepoint for validation, logging, and future refactoring without breaking existing consumers.
As implemented in ryanmcdermott/clean-code-javascript, encapsulating balance behind methods prevents invalid states like negative numbers and allows you to change the internal representation later.
// ❌ Direct property mutation allows invalid state
function makeBankAccount() {
return { balance: 0 };
}
const account = makeBankAccount();
account.balance = -100; // no guard rails
// ✅ Encapsulated access with validation
function makeBankAccount() {
let balance = 0;
return {
getBalance() { return balance; },
setBalance(amount) {
if (amount < 0) throw new Error('Negative balance');
balance = amount;
}
};
}
const account = makeBankAccount();
account.setBalance(100); // safe, validated write
Hide Internal State with Private Members
Make object members private using closures or ES2020 private fields (#private). Public APIs should expose only what callers absolutely need to prevent external mutation of internal invariants.
The guide demonstrates this in README.md using factory functions that close over variables, rendering them inaccessible to external delete operations or direct tampering.
// ❌ Public field can be deleted or tampered externally
function Employee(name) {
this.name = name;
}
Employee.prototype.getName = function () { return this.name; };
const emp = new Employee('John');
delete emp.name; // breaks the object
// ✅ Private via closure - name is protected
function makeEmployee(name) {
return {
getName() { return name; }
};
}
const emp = makeEmployee('John');
delete emp.name; // no effect, name stays hidden in closure
Set Default Objects Using Object.assign or Spread
Create default configurations with Object.assign or the spread operator rather than mutating input arguments. This guarantees the original reference remains unchanged and makes default values explicit.
The createMenu example in the source documentation shows how to merge defaults with user-supplied overrides without side effects.
// ❌ Mutates the argument and mixes defaults manually
function createMenu(config) {
config.title = config.title || 'Default Title';
// ... mutates config directly
}
// ✅ Pure function - returns new object, preserves input
function createMenu(config = {}) {
const defaults = {
title: 'Default Title',
body: '',
buttonText: 'OK',
cancellable: true
};
return Object.assign({}, defaults, config);
// or: return { ...defaults, ...config };
}
Avoid Unintended Side Effects
Never share mutable state unintentionally. Functions receiving objects should treat them as immutable unless explicitly documented otherwise. Clone data when you need to modify it.
This principle, detailed in the Avoid Side Effects sections of README.md, prevents bugs where changes in one module silently break another module holding a reference to the original object.
// ❌ Mutates the shared cart array unexpectedly
function addItemToCart(cart, item) {
cart.push(item); // callers see the modification
}
// ✅ Returns a new array, leaves original untouched
function addItemToCart(cart, item) {
return [...cart, item];
}
const originalCart = [{ id: 1 }];
const newCart = addItemToCart(originalCart, { id: 2 });
// originalCart remains [{ id: 1 }]
Prefer Plain Objects Over Premature Classes
Use simple literal objects or factory functions for lightweight data structures. Only introduce classes when you need inheritance, complex encapsulation, or multiple instances with shared behavior.
This approach reduces boilerplate and leverages JavaScript's native object semantics, as recommended in the Objects and Data Structures overview section of the guide.
// ❌ Unnecessary class for simple data
class Car {
constructor(make, model) {
this.make = make;
this.model = model;
}
}
// ✅ Plain object is sufficient
const Car = {
make: 'Honda',
model: 'Accord'
};
// Or use a factory function for creation logic
function createCar(make, model) {
return { make, model };
}
Summary
- Remove redundant context from property names to keep objects terse and readable.
- Use getters and setters to encapsulate access and enable validation logic.
- Hide members with closures to prevent external tampering and protect invariants.
- Apply defaults with
Object.assignor spread to avoid mutating input objects. - Clone instead of mutate when modifying shared data structures to prevent side effects.
- Choose plain objects over classes unless you need complex behavior or inheritance.
Frequently Asked Questions
Should I use classes or plain objects for simple data containers?
Prefer plain objects or factory functions for simple data structures. According to ryanmcdermott/clean-code-javascript, classes introduce unnecessary boilerplate when you only need to group related data. Reserve classes for scenarios requiring inheritance, complex encapsulation, or multiple instances with methods that operate on shared state.
How do I prevent external code from modifying my object's internal state?
Use closures to create truly private members. Define variables inside a factory function and return methods that access those variables, as shown in the makeEmployee pattern. Alternatively, use ES2020 private fields (#fieldName) within classes. This prevents external code from deleting or reassigning critical properties that maintain your object's invariants.
What is the safest way to handle default values in function parameters?
Merge defaults using Object.assign({}, defaults, config) or the spread operator { ...defaults, ...config }. This approach, documented in the README.md of ryanmcdermott/clean-code-javascript, ensures you never mutate the caller's input object while clearly declaring what defaults apply when arguments are omitted.
Why should I avoid mutating objects passed into functions?
Mutating shared references creates action at a distance bugs where changes in one function break unrelated code holding the same reference. The guide emphasizes treating input objects as immutable and returning new copies (using ...spread or Object.assign) to eliminate hidden coupling between modules.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →