If you've ever asked yourself "Why is this undefined here?" or "Should I mutate this object or copy it?", this guide is for you.
Functions and objects are the two pillars of JavaScript. Almost everything you build, from a React component to an Express route, is a function operating on an object. In this article we'll go step by step through:
- The four kinds of functions and how they differ
- What happens when functions live inside objects
- Complete CRUD (Create, Read, Update, Delete) on objects, including immutable patterns
All examples are ES6+ and can be pasted into Node or your browser console. Let's go. 🚀
Section 1: Demystifying JavaScript Functions
1.1 Named Function Declarations
A function declaration is the classic way to define a function. It starts with the function keyword, followed by a required name.
// Syntax: function name(parameters) { body }
function calculateCartTotal(prices) {
// reduce sums every price, starting from 0
return prices.reduce((sum, price) => sum + price, 0);
}
console.log(calculateCartTotal([19.99, 5.5, 3.25]));
// Output: 28.74
Use cases: reusable utilities, top-level helpers, and any function you want available across the whole file.
Hoisting: why you can call it before it's defined
Before JavaScript runs your code, the engine creates an execution context in two phases:
- Creation phase: the engine scans the scope, registers variables and function declarations, and stores the entire function in memory.
- Execution phase: code runs line by line.
Because declarations are fully stored during the creation phase, this works:
// ✅ AFTER-style call: works even though the definition is below
console.log(greetUser("Asha"));
// Output: Hello, Asha!
function greetUser(name) {
return `Hello, ${name}!`;
}
// The engine effectively sees it like this:
// [creation phase] greetUser -> function (fully available)
// [execution phase] console.log(greetUser("Asha"))
Now compare with a function expression (more in the next section):
// ❌ BEFORE the definition: the variable exists but is not initialized
try {
console.log(sayHi("Asha"));
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
// Output: ReferenceError: Cannot access 'sayHi' before initialization
const sayHi = function (name) {
return `Hi, ${name}!`;
};
💡 Tip:
constandletare hoisted too, but they sit in the Temporal Dead Zone (TDZ) until their line runs. With old-schoolvar, you'd getTypeError: sayHi is not a functioninstead, becausevaris hoisted asundefined.
1.2 Function Expressions
A function expression creates a function as part of an expression and typically assigns it to a variable.
// Assigned to a const: the standard modern choice
const formatPrice = function (amount) {
return `$${amount.toFixed(2)}`;
};
console.log(formatPrice(9.5));
// Output: $9.50
💡 Tip: Prefer
constfor function expressions so you can't accidentally reassign the function later.
Anonymous vs. Named Function Expressions (NFE)
// Anonymous function expression: no name after `function`
const double = function (n) {
return n * 2;
};
// Named function expression (NFE): the name "fact" is *internal*
const factorial = function fact(n) {
// The internal name lets the function call itself reliably
return n <= 1 ? 1 : n * fact(n - 1);
};
console.log(factorial(5));
// Output: 120
console.log(typeof fact);
// Output: undefined (the NFE name is only visible INSIDE the function)
Why NFEs are useful:
| Benefit | Explanation |
|---|---|
| Better stack traces | Errors show at fact (file.js:3) instead of at <anonymous>
|
| Safe recursion |
fact always points to this function, even if the outer variable is reassigned |
| Self-documenting | The name describes what the function does |
💡 Tip: Modern engines infer a name when you write
const double = function () {}. The real win for NFEs is on inline functions (like callbacks) that have no variable to infer from.
1.3 Anonymous Functions
A function is anonymous when it has no name of its own. You'll mostly see them where a function is passed as a value.
As callbacks
// With setTimeout
setTimeout(function () {
console.log("Session expired!");
}, 1000);
// Output (after 1 second): Session expired!
// With array iterator methods
const products = [
{ name: "Keyboard", price: 49 },
{ name: "Mouse", price: 25 },
{ name: "Monitor", price: 199 },
];
const affordable = products.filter(function (product) {
return product.price < 100;
});
console.log(affordable.map((p) => p.name));
// Output: [ 'Keyboard', 'Mouse' ]
IIFEs (Immediately Invoked Function Expressions)
An IIFE defines a function and runs it right away.
(function () {
const secret = "only visible in here";
console.log("IIFE executed!");
})();
// Output: IIFE executed!
// console.log(secret); // ❌ ReferenceError: secret is not defined
Historical usage: before ES6, var was function-scoped only, and there were no modules. IIFEs were the go-to trick to avoid polluting the global scope and to create "private" variables (the module pattern).
// Classic module pattern with a private counter
const counter = (function () {
let count = 0; // private: can't be touched from outside
return {
increment: () => ++count,
getCount: () => count,
};
})();
counter.increment();
counter.increment();
console.log(counter.getCount());
// Output: 2
Modern usage: let/const give block scope and ES modules give file scope, so IIFEs are much rarer. They're still handy for top-level await alternatives and one-off setup code:
(async () => {
const response = await fetch("https://api.example.com/user");
console.log(response.status);
})();
1.4 Arrow Functions (ES6+)
Arrow functions give you shorter syntax and different this behavior.
Syntax variations
// 1. Standard arrow function with a block body (explicit return needed)
const add = (a, b) => {
return a + b;
};
// 2. Implicit return: drop the braces and `return`
const addShort = (a, b) => a + b;
// 3. Single argument: parentheses are optional
const square = (n) => n * n;
const squareNoParens = n => n * n;
// 4. No arguments: parentheses are required
const getTimestampLabel = () => "Now";
// 5. Returning an object literal: wrap it in parentheses!
const createUser = (name) => ({ name, active: true });
console.log(add(2, 3)); // Output: 5
console.log(square(4)); // Output: 16
console.log(createUser("Riya")); // Output: { name: 'Riya', active: true }
⚠️ Warning: Without the parentheses,
() => { name: "Riya" }is parsed as a block with a label, not an object, and it returnsundefined.
const broken = (name) => { name, active: true }; // ❌ SyntaxError
const alsoBroken = (name) => { return; }; // returns undefined
Lexical this: the big difference
- A regular
functioncreates its ownthis, decided by how it's called. - An arrow function has no
thisof its own. It inheritsthisfrom the surrounding scope (lexical scoping).
const player = {
name: "Kai",
hobbies: ["chess", "cycling"],
// Regular function inside map: gets its OWN `this`
listWithFunction() {
return this.hobbies.map(function (hobby) {
return `${this.name} likes ${hobby}`;
});
},
// Arrow function inside map: inherits `this` from listWithArrow()
listWithArrow() {
return this.hobbies.map((hobby) => `${this.name} likes ${hobby}`);
},
};
console.log(player.listWithArrow());
// Output: [ 'Kai likes chess', 'Kai likes cycling' ]
console.log(player.listWithFunction());
// Output (non-strict mode): [ 'undefined likes chess', 'undefined likes cycling' ]
// In strict mode or ES modules this throws:
// TypeError: Cannot read properties of undefined (reading 'name')
Arrow functions also have no arguments object and can't be used with new:
const Person = (name) => { this.name = name; };
// new Person("Asha"); // ❌ TypeError: Person is not a constructor
Section 2: Objects Meet Functions (Object Methods)
When a function is stored as a property of an object, we call it a method.
2.1 Shorthand Method Syntax
const userProfile = {
name: "Meera",
// Old way (ES5): property assigned a function expression
greetOld: function () {
return `Hi, I'm ${this.name}`;
},
// ES6 shorthand: cleaner, same behavior
greet() {
return `Hi, I'm ${this.name}`;
},
};
console.log(userProfile.greet());
// Output: Hi, I'm Meera
💡 Tip: Shorthand methods can use
superand are the idiomatic choice in modern code. They also can't be used as constructors, which is usually what you want for a method.
2.2 Dynamic Binding of this
In a standard method, this is determined at call time. The rule of thumb is that it points to the object to the left of the dot.
const account = {
holder: "Arjun",
balance: 1500,
describe() {
return `${this.holder} has ₹${this.balance}`;
},
};
console.log(account.describe());
// Output: Arjun has ₹1500 (this === account)
// The SAME function, called with a different object on the left:
const otherAccount = { holder: "Sana", balance: 800, describe: account.describe };
console.log(otherAccount.describe());
// Output: Sana has ₹800 (this === otherAccount)
// Detach the method and `this` is lost:
const detached = account.describe;
// detached(); // ❌ strict mode: TypeError. Sloppy mode: "undefined has ₹undefined"
// ✅ Fix with bind (creates a new function with a permanently fixed `this`)
const bound = account.describe.bind(account);
console.log(bound());
// Output: Arjun has ₹1500
⚠️ Warning: This is exactly why passing
obj.methodas a callback (e.g.,button.addEventListener("click", obj.method)) often breaks. Use.bind()or wrap it in an arrow function.
2.3 Why You Should (Almost) Never Use Arrow Functions as Object Methods
Arrow functions don't get their own this, and an object literal is not a scope. So this inside an arrow "method" points to whatever this is outside the object (the module, or window in a classic browser script).
// ❌ BROKEN
const brokenUser = {
username: "dev_ninja",
sayHello: () => `Hi, ${this.username}!`,
};
console.log(brokenUser.sayHello());
// Output: Hi, undefined!
// (in a classic script or CommonJS; in an ES module `this` is undefined,
// so it throws a TypeError instead)
// ✅ FIX: use shorthand method syntax
const fixedUser = {
username: "dev_ninja",
sayHello() {
return `Hi, ${this.username}!`;
},
};
console.log(fixedUser.sayHello());
// Output: Hi, dev_ninja!
The winning pattern is to use a regular method on the object, and arrow functions inside it for callbacks (as we saw with listWithArrow).
💡 Tip: The exception is when you deliberately don't want dynamic
this, such as a method that never touchesthis. Even then, shorthand syntax keeps things consistent.
Section 3: Complete CRUD Operations on Objects
Let's build up a realistic example: a user profile and a shopping cart.
3.1 Create (Add Properties)
Dot vs. bracket notation
const user = {};
// Dot notation: clean, but the key must be a valid identifier
user.name = "Priya";
user.age = 27;
// Bracket notation: needed for spaces, dashes, or dynamic keys
user["favorite-color"] = "teal";
user["full name"] = "Priya Sharma";
const field = "email";
user[field] = "priya@example.com"; // key comes from a variable
console.log(user);
// Output: {
// name: 'Priya',
// age: 27,
// 'favorite-color': 'teal',
// 'full name': 'Priya Sharma',
// email: 'priya@example.com'
// }
Computed property names
Build dynamic keys right inside the object literal:
const settingKey = "theme";
const settings = {
[settingKey]: "dark", // key becomes "theme"
[`${settingKey}Version`]: 2, // key becomes "themeVersion"
language: "en",
};
console.log(settings);
// Output: { theme: 'dark', themeVersion: 2, language: 'en' }
Creating copies: Object.assign() and spread
const defaults = { theme: "light", notifications: true };
const overrides = { theme: "dark" };
// Object.assign(target, ...sources): copies into the FIRST argument
const merged1 = Object.assign({}, defaults, overrides);
// Spread: the modern, more readable equivalent
const merged2 = { ...defaults, ...overrides };
console.log(merged1);
// Output: { theme: 'dark', notifications: true }
console.log(merged2);
// Output: { theme: 'dark', notifications: true }
⚠️ Warning:
Object.assign(defaults, overrides)(without the empty{}first) mutatesdefaults. Always pass{}as the target when you want a fresh copy.⚠️ Warning: Both approaches are shallow copies. Nested objects are still shared by reference:
const original = { name: "Leo", address: { city: "Chennai" } };
const copy = { ...original };
copy.address.city = "Mumbai";
console.log(original.address.city);
// Output: Mumbai 😱 (the nested object is shared!)
3.2 Read (Access & Inspect)
Safe access with ?. and ??
const customer = {
name: "Dev",
address: { city: "Pune" },
loyaltyPoints: 0,
};
// ❌ Without optional chaining, missing parents throw
// customer.billing.zip; // TypeError: Cannot read properties of undefined
// ✅ Optional chaining: returns undefined instead of throwing
console.log(customer.billing?.zip);
// Output: undefined
// Nullish coalescing (??): fallback ONLY for null/undefined
console.log(customer.billing?.zip ?? "No ZIP on file");
// Output: No ZIP on file
// Why ?? beats ||: it respects valid falsy values like 0
console.log(customer.loyaltyPoints ?? 100); // Output: 0
console.log(customer.loyaltyPoints || 100); // Output: 100 ← wrong!
// Optional chaining also works on methods and arrays
console.log(customer.getDiscount?.()); // Output: undefined
console.log(customer.orders?.[0]); // Output: undefined
Inspecting objects
const cart = { laptop: 1, mouse: 2, keyboard: 1 };
console.log(Object.keys(cart));
// Output: [ 'laptop', 'mouse', 'keyboard' ]
console.log(Object.values(cart));
// Output: [ 1, 2, 1 ]
console.log(Object.entries(cart));
// Output: [ [ 'laptop', 1 ], [ 'mouse', 2 ], [ 'keyboard', 1 ] ]
// Iterating entries with for...of and destructuring
for (const [item, quantity] of Object.entries(cart)) {
console.log(`${item} x${quantity}`);
}
// Output:
// laptop x1
// mouse x2
// keyboard x1
Checking property existence
const profile = { name: "Zara", nickname: undefined };
// `in` checks the object AND its prototype chain
console.log("name" in profile); // Output: true
console.log("toString" in profile); // Output: true (inherited from Object.prototype!)
// Object.hasOwn() checks ONLY the object's own properties (ES2022)
console.log(Object.hasOwn(profile, "name")); // Output: true
console.log(Object.hasOwn(profile, "toString")); // Output: false
console.log(Object.hasOwn(profile, "nickname")); // Output: true (exists, value is undefined)
// Older equivalent (works everywhere)
console.log(profile.hasOwnProperty("name")); // Output: true
💡 Tip: Prefer
Object.hasOwn(obj, key)overobj.hasOwnProperty(key). It also works on objects created withObject.create(null), which have nohasOwnPropertymethod.
3.3 Update (Modify Properties)
Mutable in-place updates
const settings = { theme: "light", fontSize: 14 };
settings.theme = "dark"; // change an existing property
settings.fontSize += 2; // compute from the old value
console.log(settings);
// Output: { theme: 'dark', fontSize: 16 }
Simple and fast, but it changes the original object. Anything else holding a reference to it sees the change.
Immutable updates with spread
const oldSettings = { theme: "light", fontSize: 14 };
// Copy everything, then override what changed
const newSettings = { ...oldSettings, theme: "dark" };
console.log(oldSettings);
// Output: { theme: 'light', fontSize: 14 } ← untouched
console.log(newSettings);
// Output: { theme: 'dark', fontSize: 14 }
console.log(oldSettings === newSettings);
// Output: false (a brand-new reference)
💡 Tip: Immutable updates are crucial for React, Vue (reactive refs), and Redux. These tools detect changes by comparing references. If you mutate in place, the reference stays the same and your UI may not re-render.
// React-style state update
// setUser(prev => ({ ...prev, name: "New Name" }));
Deep nested updates
const state = {
user: {
name: "Nikhil",
preferences: { theme: "light", language: "en" },
},
isLoggedIn: true,
};
// 1️⃣ Mutable: simple, but modifies the original
state.user.preferences.theme = "dark";
// 2️⃣ Immutable: spread at EVERY level along the path you're changing
const nextState = {
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: "dark",
},
},
};
console.log(nextState.user.preferences);
// Output: { theme: 'dark', language: 'en' }
💡 Tip: Deeply nested spreads get ugly fast. Options: flatten your state shape, use a helper like Immer, or use
structuredClone()(built-in) for a true deep copy before mutating:
const deepCopy = structuredClone(state);
deepCopy.user.preferences.language = "ta";
console.log(state.user.preferences.language);
// Output: en (the original is safe)
3.4 Delete (Remove Properties)
Mutable deletion with delete
const account = { id: 101, email: "a@b.com", tempToken: "xyz123" };
const wasDeleted = delete account.tempToken;
console.log(wasDeleted); // Output: true
console.log(account); // Output: { id: 101, email: 'a@b.com' }
⚠️ Warning (performance): JavaScript engines like V8 optimize objects with predictable shapes ("hidden classes"). Deleting properties can push an object into slower dictionary mode. In hot code paths, consider setting the value to
undefined/null, or use the immutable approach below. For everyday code, it's perfectly fine.
Immutable removal with rest destructuring
const userWithPassword = {
id: 7,
name: "Isha",
password: "super-secret",
role: "admin",
};
// Pull out the key you want to drop; collect everything else in `safeUser`
const { password, ...safeUser } = userWithPassword;
console.log(safeUser);
// Output: { id: 7, name: 'Isha', role: 'admin' }
console.log(userWithPassword.password);
// Output: super-secret (original is intact)
💡 Tip: If your linter complains about the unused
passwordvariable, rename it with an underscore (_password) or configureignoreRestSiblings.
Object Immutability Levels
JavaScript gives you three built-in levels of protection:
| Method | Add props | Delete props | Modify values | Check with |
|---|---|---|---|---|
Object.preventExtensions() |
❌ | ✅ | ✅ | Object.isExtensible() |
Object.seal() |
❌ | ❌ | ✅ | Object.isSealed() |
Object.freeze() |
❌ | ❌ | ❌ | Object.isFrozen() |
"use strict"; // strict mode makes violations throw instead of failing silently
// 1️⃣ preventExtensions: no NEW properties
const a = { x: 1 };
Object.preventExtensions(a);
try { a.y = 2; } catch (e) { console.log(e.message); }
// Output: Cannot add property y, object is not extensible
delete a.x; // still allowed
console.log(a);
// Output: {}
// 2️⃣ seal: no add, no delete, but values can change
const b = { x: 1 };
Object.seal(b);
b.x = 99; // ✅ allowed
try { delete b.x; } catch (e) { console.log(e.message); }
// Output: Cannot delete property 'x' of #<Object>
console.log(b);
// Output: { x: 99 }
// 3️⃣ freeze: completely read-only
const config = { apiUrl: "https://api.example.com", retries: 3 };
Object.freeze(config);
try { config.retries = 5; } catch (e) { console.log(e.message); }
// Output: Cannot assign to read only property 'retries' of object '#<Object>'
console.log(Object.isFrozen(config));
// Output: true
⚠️ Warning: Without
"use strict", these violations fail silently. Your code keeps running, but nothing changes. ES modules and classes are strict by default.
Shallow freeze vs. deep freeze
Object.freeze() is shallow. Nested objects stay mutable:
const appConfig = Object.freeze({
name: "MyApp",
database: { host: "localhost", port: 5432 },
});
appConfig.name = "Hacked"; // ❌ ignored (top level is frozen)
appConfig.database.port = 9999; // ✅ works! nested object isn't frozen
console.log(appConfig);
// Output: { name: 'MyApp', database: { host: 'localhost', port: 9999 } }
To freeze everything, write a recursive deep freeze:
function deepFreeze(obj) {
// Freeze nested objects first (children before parent)
Object.values(obj).forEach((value) => {
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
const safeConfig = deepFreeze({
name: "MyApp",
database: { host: "localhost", port: 5432 },
});
safeConfig.database.port = 9999; // ❌ blocked (throws in strict mode)
console.log(safeConfig.database.port);
// Output: 5432
Quick Comparison: The 4 Function Types
| Feature | Function Declaration | Function Expression | Anonymous Function | Arrow Function |
|---|---|---|---|---|
| Syntax | function name() {} |
const f = function() {} |
function() {} (no name, used inline) |
const f = () => {} |
| Hoisting | ✅ Fully hoisted (callable before definition) | ❌ Not usable before definition (TDZ with const/let) |
❌ N/A (depends on where it's assigned or passed) | ❌ Not usable before definition (TDZ) |
this binding |
Dynamic (set by call site) | Dynamic (set by call site) | Dynamic (set by call site) | Lexical (inherited from enclosing scope) |
| Best use case | Top-level reusable utilities | Conditional definitions, NFEs for recursion | Short callbacks, IIFEs | Callbacks, array methods, keeping outer this
|
Key Takeaways
- Declarations are hoisted, so they're great for top-level helpers.
- Expressions aren't hoisted, and named ones give better stack traces and safer recursion.
-
Arrow functions are perfect for callbacks because they inherit
this, but avoid them as object methods. -
CRUD on objects: prefer spread for immutable creates/updates, rest destructuring for immutable deletes, and reach for
freezewhen you need guarantees. - Remember that spread and
Object.freezeare both shallow. UsestructuredClone()anddeepFreeze()when depth matters.
Conclusion
Once functions and object CRUD click, a huge chunk of JavaScript (React state, API payloads, config handling) starts to feel a lot less mysterious. 🎉
Now I'd love to hear from you:
- Do you default to arrow functions everywhere, or do you still reach for function declarations?
- Spread,
structuredClone, or Immer for nested updates? -
Have you ever been bitten by a
thisbug or a shallow-copy surprise? Share your story!
Drop your preferred patterns in the comments below. 👇 And if this guide helped, a ❤️ or 🦄 goes a long way!
Happy coding! 💻
Top comments (0)