DEV Community

Surya Kanth
Surya Kanth

Posted on

JavaScript Functions & Object CRUD: The Complete Guide from Declarations to Object.freeze()

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:

  1. The four kinds of functions and how they differ
  2. What happens when functions live inside objects
  3. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Creation phase: the engine scans the scope, registers variables and function declarations, and stores the entire function in memory.
  2. 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"))
Enter fullscreen mode Exit fullscreen mode

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}!`;
};
Enter fullscreen mode Exit fullscreen mode

💡 Tip: const and let are hoisted too, but they sit in the Temporal Dead Zone (TDZ) until their line runs. With old-school var, you'd get TypeError: sayHi is not a function instead, because var is hoisted as undefined.


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
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Prefer const for 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)
Enter fullscreen mode Exit fullscreen mode

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' ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
})();
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

⚠️ Warning: Without the parentheses, () => { name: "Riya" } is parsed as a block with a label, not an object, and it returns undefined.

const broken = (name) => { name, active: true }; // ❌ SyntaxError
const alsoBroken = (name) => { return; };        // returns undefined
Enter fullscreen mode Exit fullscreen mode

Lexical this: the big difference

  • A regular function creates its own this, decided by how it's called.
  • An arrow function has no this of its own. It inherits this from 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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Shorthand methods can use super and 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
Enter fullscreen mode Exit fullscreen mode

⚠️ Warning: This is exactly why passing obj.method as 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!
Enter fullscreen mode Exit fullscreen mode

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 touches this. 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'
// }
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

⚠️ Warning: Object.assign(defaults, overrides) (without the empty {} first) mutates defaults. 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!)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 Tip: Prefer Object.hasOwn(obj, key) over obj.hasOwnProperty(key). It also works on objects created with Object.create(null), which have no hasOwnProperty method.


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 }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

💡 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" }));
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

💡 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)
Enter fullscreen mode Exit fullscreen mode

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' }
Enter fullscreen mode Exit fullscreen mode

⚠️ 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)
Enter fullscreen mode Exit fullscreen mode

💡 Tip: If your linter complains about the unused password variable, rename it with an underscore (_password) or configure ignoreRestSiblings.


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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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 } }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 freeze when you need guarantees.
  • Remember that spread and Object.freeze are both shallow. Use structuredClone() and deepFreeze() 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 this bug 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)