Difference Between Methods and Functions in JavaScript (A Practical Guide)

The first time I debugged a “this is undefined” error in a production checkout flow, I assumed JavaScript was being temperamental. It wasn’t. I had written a perfectly valid function, but I was calling it like a method, and the context vanished when I passed it around. That moment stuck with me because it was a small bug with a big lesson: functions and methods look similar, yet they behave differently in ways that matter in real systems.

If you’ve ever wondered why a callback loses its context, why a method works when called directly but fails when passed to a timer, or why some APIs are written as standalone utilities while others hang off objects, you’re in the right place. I’m going to walk you through what separates methods from functions, where each shines, and how I decide which to use in modern JavaScript. I’ll also show common mistakes I see in code reviews, plus practical fixes that align with 2026-era practices and tooling.

The Core Definitions I Use in Daily Work

A function is a standalone block of reusable behavior. It can accept arguments, return a value, and live anywhere: top-level, inside another function, or exported from a module. A method is a function that lives on an object and is meant to operate on that object’s data. The important nuance is that a method receives its object context implicitly through this (or through an explicit receiver), while a function receives all of its data explicitly through parameters.

That distinction affects how you call the code, how you test it, and how it behaves when you pass it around. In my experience, most “mysterious” JavaScript bugs are actually simple context mistakes. When you know whether you’re dealing with a method or a function, you avoid those traps before they bite.

How Calling Style Changes Behavior

Methods are invoked with an object receiver, which sets the this value inside the method. Functions are invoked without a receiver and do not get an implicit object context. That one line is the root of almost every difference.

Here’s a quick visual example using a real-world domain: a shopping cart.

const cart = {

items: [{ sku: "TEE-BLACK-M", qty: 1 }],

addItem(item) {

// this refers to cart when called as a method

this.items.push(item);

}

};

cart.addItem({ sku: "HAT-GREY", qty: 2 });

Now compare that with a standalone function:

function addItem(items, item) {

// No implicit object; all data comes from parameters

return [...items, item];

}

const updated = addItem(cart.items, { sku: "HAT-GREY", qty: 2 });

Both do the same work, but the method reads its data from this, while the function reads it from its arguments. That difference becomes critical when you pass code around, store it in variables, or use it as a callback.

this Is the Real Divider

When I review JavaScript code, I scan for how this is used. That tells me immediately whether the author intended a method or a function. this is not a universal constant in JavaScript; it’s a value determined by the call site. That means the same piece of code can behave differently depending on how it’s invoked.

Consider this practical example with a user profile object:

const profile = {

displayName: "Ari Gomez",

greet() {

return Hello, ${this.displayName}!;

}

};

const greetLater = profile.greet;

console.log(greetLater()); // this is undefined in strict mode

The method works when called as profile.greet(), but fails when you call it as a standalone function. If the code inside depends on this, it must be called as a method or bound explicitly.

If you truly want a function, write it to accept all data explicitly and avoid this entirely. That makes it portable and predictable, especially for callbacks and higher-order functions.

Object Methods, Class Methods, and Prototypes in Modern Practice

In 2026, most teams still use a mix of object literals, classes, and prototype-based patterns. Methods can exist in all three forms, but the call-site rule stays the same.

Object literal methods

const session = {

userId: "u_9821",

isActive() {

return Boolean(this.userId);

}

};

Class methods

class Session {

constructor(userId) {

this.userId = userId;

}

isActive() {

return Boolean(this.userId);

}

}

const s = new Session("u_9821");

console.log(s.isActive());

Prototype methods

function Session(userId) {

this.userId = userId;

}

Session.prototype.isActive = function () {

return Boolean(this.userId);

};

From a behavior standpoint, these are all methods. The important thing is how they are called: s.isActive(). When you detach a method and call it without its receiver, this changes. That’s the biggest reason I keep most methods very small and push heavy logic into pure functions.

Functions as Building Blocks (and Why I Prefer Them for Logic)

When I’m modeling core business rules—pricing, validation, formatting—I prefer pure functions. They’re easier to test, easier to reuse, and easier to reason about in asynchronous or concurrent flows. This fits modern codebases that heavily use async workflows, background jobs, and AI-assisted pipelines.

Example: calculate a price with tax and discounts.

function computeTotal({ subtotal, taxRate, discount }) {

// Explicit inputs, no reliance on external state

const taxed = subtotal * (1 + taxRate);

const final = Math.max(0, taxed - discount);

return Math.round(final * 100) / 100;

}

const total = computeTotal({ subtotal: 49.99, taxRate: 0.07, discount: 5 });

That function can be used in a web server, a background worker, or a client UI with zero changes. If I had embedded it as a method, I would have coupled it to a specific object shape, which makes it harder to share across modules.

When Methods Are the Better Fit

Methods shine when behavior belongs to an object’s identity or lifecycle. If the action is inseparable from the data, I prefer a method.

Example: a connection object that manages its own state.

class RealtimeConnection {

constructor(socket) {

this.socket = socket;

this.isOpen = false;

}

open() {

this.socket.connect();

this.isOpen = true;

}

close() {

this.socket.disconnect();

this.isOpen = false;

}

}

If I stripped these into standalone functions, I would lose the object’s internal state and end up passing more arguments than necessary. Methods keep stateful behavior cohesive.

Typical Mistakes I See (and How I Fix Them)

These are the patterns that appear in code reviews again and again. I’ll show the mistake and the fix that I recommend.

1) Losing this in callbacks

const notifier = {

prefix: "[ALERT]",

send(message) {

console.log(${this.prefix} ${message});

}

};

setTimeout(notifier.send, 1000); // this is undefined

Fix with binding:

setTimeout(notifier.send.bind(notifier, "Server down"), 1000);

Or convert to a function:

function send(prefix, message) {

console.log(${prefix} ${message});

}

setTimeout(() => send("[ALERT]", "Server down"), 1000);

2) Using a method for stateless logic

const math = {

sum(a, b) {

return a + b;

}

};

This is harmless, but I avoid it when there’s no object state. A pure function is clearer:

function sum(a, b) {

return a + b;

}

3) Confusing property access with method invocation

If a method is meant to run, you must use parentheses.

const device = {

status: "ok",

statusText() {

return Device status: ${this.status};

}

};

console.log(device.statusText); // function reference, not a call

console.log(device.statusText()); // correct invocation

4) Overusing this in arrow functions

Arrow functions do not define their own this. If you use them as methods and expect this to change, you’ll get surprising results.

const account = {

owner: "Priya",

showOwner: () => {

// this is not account here

return this.owner;

}

};

console.log(account.showOwner()); // undefined

Fix by using a method shorthand or a normal function:

const account = {

owner: "Priya",

showOwner() {

return this.owner;

}

};

Performance Considerations (What Actually Matters)

In most modern apps, method vs function doesn’t matter for raw runtime performance. The differences are more about memory and allocation patterns.

  • Methods defined on prototypes are shared across instances, which typically keeps memory stable.
  • Functions defined inside constructors or object literals can create new function instances per object, which can add overhead in large collections.
  • The performance difference usually shows up only at scale; I typically see measurable impact in systems with tens of thousands of objects or more.

If you’re building a UI or API that isn’t instantiating huge volumes of objects, focus on clarity and correctness first. If you are building something like a simulation or a real-time data pipeline, prototype methods and standalone functions are safer and more predictable.

Real-World Scenarios and How I Decide

When deciding between a function and a method, I ask two questions:

1) Does this behavior depend on object state? If yes, I choose a method.

2) Do I want this behavior to be easily reused across modules and environments? If yes, I choose a function.

Here are a few practical examples with direct recommendations.

Scenario: Formatting data for display

I prefer a function. Formatting is usually stateless and reused across pages.

function formatCurrency(amount, locale = "en-US", currency = "USD") {

return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);

}

Scenario: Managing a connection or session

I prefer methods. The behavior is tied to internal state.

class Session {

constructor(id) {

this.id = id;

this.active = true;

}

end() {

this.active = false;

}

}

Scenario: Validating form input

Functions work better, and you can group them in a module. It also makes them easier to test in isolation.

function isValidEmail(value) {

return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);

}

Scenario: UI components

If you’re in React or another component model, you’ll commonly use functions for components and methods only when working with classes or instance-like objects. The ecosystem trend strongly favors functions for component logic.

A Quick Decision Table: Traditional vs Modern Approach

When I modernize legacy code, I often refactor toward these patterns.

Concern

Traditional

Modern (What I Prefer) —

— Object behavior

Methods that mutate state

Methods that encapsulate state changes cleanly Pure logic

Utility methods on objects

Standalone pure functions Callbacks

Methods passed around unbound

Functions or bound methods Testing

Mixed mock-heavy tests

Pure function tests, small method tests Code reuse

Methods locked to objects

Functions exported from modules

If you’re writing new code in 2026, the modern approach makes your code more portable and AI-tool-friendly. Static analysis, code generation, and automated refactors all work better with pure, explicit functions.

Edge Cases You Should Know About

Some JavaScript behaviors are not obvious until you hit them in production.

Borrowing methods with call or apply

You can use a method as if it were a function by explicitly setting this.

const logger = {

prefix: "[INFO]",

log(message) {

console.log(${this.prefix} ${message});

}

};

logger.log.call({ prefix: "[DEBUG]" }, "Cache hit");

This is powerful but easy to misuse. I usually keep it for rare cases like polyfills or legacy code.

Destructuring and losing context

const user = {

name: "Jordan",

greet() {

return Hi ${this.name};

}

};

const { greet } = user;

console.log(greet()); // undefined

Fix by binding or by turning greet into a pure function.

Methods on built-in objects

Built-in methods like Array.prototype.map are methods because they depend on the array instance. That’s why you call items.map(...) instead of map(items, ...). You can still borrow them:

const map = Array.prototype.map;

const result = map.call([1, 2, 3], n => n * 2);

I only do this in low-level utilities or to interoperate with array-like objects.

How This Maps to Modern Tooling and Practices

In 2026, I see more teams leaning on AI-assisted tooling for refactors and for extracting reusable logic. Those tools work best with explicit, pure functions because they have fewer hidden dependencies. If you keep side effects inside methods that clearly own state, the codebase becomes easier for both humans and automated tools to reason about.

Here’s how I apply that in practice:

  • Keep stateful behavior in methods on domain objects.
  • Push computation into pure functions.
  • Pass dependencies explicitly, rather than relying on this or global state.
  • Prefer composition over inheritance for logic reuse.

This approach makes it easier to split code into modules, test it with minimal mocking, and upgrade frameworks without rewriting everything.

A Practical Checklist I Use Before Shipping

When I’m about to ship a feature, I check the following:

  • If a function relies on this, is it always called as a method? If not, bind it or refactor.
  • Is a method doing pure computation that could be a standalone function? If yes, extract it.
  • Are callbacks losing context? I bind explicitly or wrap them in arrow functions.
  • Are methods attached to objects that don’t really own state? If yes, I move them to a module.

This checklist prevents the most common runtime bugs I see in JavaScript codebases.

The Call-Site Rule in More Detail (Why It Bites)

Most confusion comes from this simple fact: this is set by how you call a function, not where the function is written. That means two calls to the same function can produce different this values. If you want to avoid surprises, you have to internalize the call-site rule.

Here are the four call patterns I watch for in debugging sessions:

1) Method call: obj.fn() sets this to obj.

2) Simple function call: fn() sets this to undefined in strict mode (or the global object in sloppy mode).

3) Explicit binding: fn.call(value, ...args) or fn.apply(value, args) sets this to value.

4) Constructor call: new Fn() sets this to the new instance.

This is why a method can look correct but still fail in a callback. The call site changed.

Example: A logger that breaks when passed around

const logger = {

prefix: "[CHECKOUT]",

log(message) {

return ${this.prefix} ${message};

}

};

function run(fn) {

return fn("step 1");

}

run(logger.log); // fails because this is undefined

Fix it by binding when you pass it in:

run(logger.log.bind(logger));

Or by refactoring the logger to a function-based API:

function log(prefix, message) {

return ${prefix} ${message};

}

run(msg => log("[CHECKOUT]", msg));

I don’t consider either “more correct.” The right choice depends on whether the logger owns state or just formats messages.

Strict Mode vs Sloppy Mode (Subtle But Important)

If you ever see a bug that only happens in one file or one build mode, check strict mode. In strict mode, a plain function call gives this === undefined. In sloppy mode, this becomes the global object (window in browsers, global in Node). That can mask bugs for months.

When you accidentally rely on sloppy mode, the bug is particularly nasty:

  • You might mutate the global object without noticing.
  • Unit tests may pass because test runners often use strict mode by default.
  • Production bundlers may enable strict mode, causing a suddenly broken release.

That’s why I always code as if this will be undefined in a plain function call. If I want an object, I pass it in or bind it.

Static Methods: The Quiet Middle Ground

A lot of developers mix up static methods with “regular” methods. Static methods are still methods, but they live on the class itself, not on instances. That changes how they should be used.

class Money {

constructor(amount) {

this.amount = amount;

}

add(other) {

return new Money(this.amount + other.amount);

}

static fromCents(cents) {

return new Money(cents / 100);

}

}

const price = Money.fromCents(1299);

I use static methods for factory-style logic that doesn’t need an instance to operate. They’re a good fit when you want to keep a namespace around a domain concept but don’t want to require this or state.

Practical rule: if it doesn’t touch instance fields, it’s a candidate for a static method or a standalone function.

Methods in Modules vs Functions in Modules

One question I get a lot is: “Should I export a class with methods or just export functions?” My default in 2026 is to export functions unless there’s real state to preserve.

Function module

// pricing.js

export function computeSubtotal(items) {

return items.reduce((sum, item) => sum + item.price * item.qty, 0);

}

export function applyDiscount(subtotal, percent) {

return subtotal * (1 - percent);

}

Class module

// pricing.js

export class CartPricing {

constructor(items) {

this.items = items;

}

subtotal() {

return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);

}

applyDiscount(percent) {

return this.subtotal() * (1 - percent);

}

}

The function module is lighter, easier to test, and easier to tree-shake. The class module gives you a single object to hold related state. I only choose the class approach when state is shared across multiple operations or when I need polymorphism.

Functions as Values (And Why It Matters)

In JavaScript, functions are first-class values. That means you can pass them around like any other value. This is powerful, but it exposes the method/function difference more than in many other languages.

When you pass a function around, you should ask:

  • Does it rely on this? If yes, bind it or wrap it.
  • Does it rely on external variables? If yes, is that dependency obvious?
  • Is it pure? If no, have you documented its side effects?

I treat every callback as if it were about to be executed in a different place, because that’s exactly what happens in async and event-driven code.

Asynchronous Code: Where Methods Often Fail

Most this bugs I see show up in async flows. The reason is simple: async code stores a reference to your function and invokes it later. That changes the call site.

Example: method used as a promise callback

const tracker = {

userId: "u_42",

report() {

return report for ${this.userId};

}

};

fetch("/api").then(tracker.report); // this lost

Fix it with a bound method:

fetch("/api").then(tracker.report.bind(tracker));

Or a pure function with explicit data:

const report = userId => report for ${userId};

fetch("/api").then(() => report("u_42"));

Both are valid; the choice depends on whether you want to keep the method identity or just produce a string.

Event Handlers: The DOM Adds Another Twist

DOM event handlers bind this to the element in traditional listeners. That can be useful, but it can also be confusing if you’re expecting lexical this.

document.querySelector("#save").addEventListener("click", function () {

// Here, this is the element

this.classList.add("loading");

});

If you change that to an arrow function, this no longer points to the element. That’s a perfect example of the method/function distinction in a real UI context. I always decide which this behavior I want before I choose the syntax.

Arrow Functions: Not Methods, Not Quite Functions

Arrow functions are often used as “shorter functions,” but they behave differently from function declarations. They capture this from the surrounding scope. That makes them great for callbacks that should not rebind this, but poor for object methods.

A safe pattern I use

class Timer {

constructor() {

this.count = 0;

}

start() {

setInterval(() => {

this.count += 1; // this works because arrow keeps lexical binding

}, 1000);

}

}

A risky pattern to avoid

const counter = {

value: 0,

inc: () => {

this.value += 1; // this is not counter

}

};

I’m not anti-arrow; I just treat them as a tool with a specific binding model. If you need a method, use a method.

API Design: Methods vs Functions as a Product Decision

Choosing between methods and functions also changes how your API feels to other developers.

Method-oriented API

cart.addItem(item).applyCoupon(code).checkout();

Function-oriented API

const updated = addItem(cart, item);

const discounted = applyCoupon(updated, code);

const receipt = checkout(discounted);

The method chain reads like a story and can be nice for fluent interfaces. The function chain is more explicit and tends to be easier to test and parallelize. When I build internal libraries, I lean toward functions; when I build developer-facing SDKs, I sometimes choose methods for ergonomics.

Testing Strategy: Why Functions Win by Default

When I’m writing tests, I want minimal setup and maximum clarity. Pure functions give me that. I can test them with simple inputs and expect deterministic outputs.

Methods require constructing an object and often involve more setup. That’s not a deal-breaker, but it’s real cost.

Function test

import { computeTotal } from "./pricing";

it("computes total with tax and discount", () => {

expect(computeTotal({ subtotal: 100, taxRate: 0.1, discount: 5 })).toBe(105);

});

Method test

import { Cart } from "./cart";

it("adds item to cart", () => {

const cart = new Cart();

cart.addItem({ sku: "A", qty: 1 });

expect(cart.items.length).toBe(1);

});

Both are fine; I just lean toward functions for logic-heavy code because the tests stay small and fast.

A Deeper Example: Splitting Methods and Functions in a Service

Here’s a more complete example from a real-style service design. The goal is to keep stateful operations in methods, but push computations into functions.

// pure functions

function normalizeEmail(email) {

return email.trim().toLowerCase();

}

function isCorporateEmail(email) {

return email.endsWith("@company.com");

}

// method-driven object

class UserService {

constructor(db) {

this.db = db;

}

async createUser(email) {

const cleanEmail = normalizeEmail(email);

const flags = { corporate: isCorporateEmail(cleanEmail) };

return this.db.users.insert({ email: cleanEmail, flags });

}

}

I keep normalizeEmail and isCorporateEmail as functions because they’re pure. The database operation stays in the method because it depends on instance state (this.db). This split scales well as the codebase grows.

Common Pitfall: “Utility Objects” That Hide Functions

A pattern I see in legacy code is a big object that exists only to hold utilities. Example:

const Utils = {

formatDate(date) { / ... / },

sum(a, b) { / ... / },

clamp(n, min, max) { / ... / }

};

These aren’t really methods because they don’t rely on state. They’re functions wearing a method costume. It’s not wrong, but it does add friction:

  • Tree-shaking might keep the entire object.
  • Testing requires importing the object even if you only need one function.
  • The method syntax hints at state that doesn’t exist.

I usually refactor these into named exports:

export function formatDate(date) { / ... / }

export function sum(a, b) { / ... / }

export function clamp(n, min, max) { / ... / }

Composition: Functions Can Mimic Methods Without this

If you like method chaining but want pure functions, composition gives you another option.

const pipe = (...fns) => value => fns.reduce((v, fn) => fn(v), value);

const applyTaxes = total => total * 1.1;

const applyDiscount = total => total - 5;

const formatTotal = total => total.toFixed(2);

const checkout = pipe(applyTaxes, applyDiscount, formatTotal);

checkout(100); // "105.00"

This is function-first design that still reads like a pipeline. It’s not always the right choice, but it’s a strong alternative when you want reuse without this.

Debugging Heuristics I Actually Use

When I’m called in to debug a “this is undefined” or “cannot read property of undefined” bug, I follow a short checklist:

1) Is the function being passed around? If yes, check the call site.

2) Does the function reference this? If yes, verify it’s called as a method.

3) Is it an arrow function inside an object? If yes, this is likely wrong.

4) Is strict mode on? If yes, this will be undefined in simple function calls.

This approach turns a vague bug into a two-minute fix most of the time.

When NOT to Use Methods

It’s easy to overuse methods. Here are the situations where I explicitly avoid them:

  • Stateless logic: calculations, formatting, and validation should be pure functions.
  • Cross-module reuse: if you expect code to be shared across layers, keep it function-based.
  • Data transformation pipelines: methods can obscure what data is flowing through the system.
  • AI-assisted refactors: tools are more accurate with explicit inputs and outputs.

In those cases, keeping logic as functions saves time and reduces bugs.

When NOT to Use Functions

And here are cases where functions are a poor fit:

  • Stateful objects: sessions, connections, caches, and component instances need methods.
  • Encapsulated invariants: if you need to guarantee certain fields are updated together, methods help protect that invariance.
  • Fluent APIs: if developer ergonomics matter, methods can be clearer.

The key is to respect where the data lives and how the behavior is expected to be called.

Practical Patterns That Avoid this Bugs

These are patterns I’ve used to prevent context bugs entirely:

1) Bind once, reuse everywhere

class Notifier {

constructor(prefix) {

this.prefix = prefix;

this.send = this.send.bind(this);

}

send(message) {

console.log(${this.prefix} ${message});

}

}

This is especially useful when you pass methods into third-party libraries that call them later.

2) Use small wrappers

const notifier = {

prefix: "[ALERT]",

send(message) {

console.log(${this.prefix} ${message});

}

};

const sendAlert = message => notifier.send(message);

3) Prefer functions for callbacks

const onUserClick = event => {

// explicit, no this

trackClick(event.target.dataset.id);

};

Each of these reduces the surface area for this issues without requiring major refactors.

A Second Decision Table: Choosing Under Constraints

Here’s a table I use when the choice isn’t obvious.

Constraint

Prefer Method

Prefer Function —

— Needs shared mutable state

❌ Must be serializable

✅ Must be easily mocked

✅ (with spies)

✅ (with direct import) High reuse across apps

✅ Callback-heavy workflow

✅ Fluent API desired

This isn’t a law, but it keeps me consistent when the pressure is high.

The “Modern JS” Angle: Modules, Tree-Shaking, and Bundlers

Modern bundlers optimize better with functions because they can tree-shake unused exports. If you export a large object full of methods, bundlers can be more conservative and include more code than you need.

Example: export functions individually for leaner bundles.

export function parseDate(value) { / ... / }

export function formatDate(value) { / ... / }

If you must export an object, make sure the bundler understands it, or accept the extra payload. This matters when you’re optimizing front-end performance, especially on mobile.

Methods and Functions in TypeScript (Quick but Useful)

TypeScript doesn’t change the runtime, but it changes how you reason about it.

  • Methods are declared on classes and objects; they implicitly use this.
  • Functions can be declared with explicit parameter types and return types.
  • You can annotate the this parameter in TypeScript functions to make intent explicit:
function log(this: { prefix: string }, message: string) {

console.log(${this.prefix} ${message});

}

This is a neat way to catch context bugs at compile time. It’s also a strong signal to readers that the function expects to be used as a method.

A Practical Comparison: Same Feature, Two Styles

Let’s build a simple “inventory manager” in both styles so you can see the trade-offs.

Method-based

class Inventory {

constructor(items = []) {

this.items = items;

}

add(item) {

this.items.push(item);

}

totalQty() {

return this.items.reduce((sum, item) => sum + item.qty, 0);

}

}

Function-based

function addItem(items, item) {

return [...items, item];

}

function totalQty(items) {

return items.reduce((sum, item) => sum + item.qty, 0);

}

Which one is better? If you need to store and mutate items, the method-based class is fine. If you want immutable data and easy testing, the function-based version is better. I often start with functions, and only introduce a class if stateful complexity appears.

Alternative Approaches: Factories and Closures

Sometimes the best approach is neither a class method nor a plain function, but a factory that returns functions with private state.

function createCounter() {

let value = 0;

return {

inc() { value += 1; },

get() { return value; }

};

}

const counter = createCounter();

counter.inc();

counter.get();

This pattern keeps state private without requiring this. The methods still exist, but they don’t rely on binding. It’s a great compromise when you want object-like behavior without context pitfalls.

How AI-Assisted Tools Change the Choice

One practical reality in 2026 is that AI tools help developers refactor and generate code. These tools are much more reliable with pure functions because the inputs and outputs are explicit. That’s not a reason to avoid methods, but it’s a good reason to make your stateful methods small and to push logic into functions where possible.

If I know a module will be heavily refactored or generated, I deliberately design it to minimize hidden state. That means:

  • Small methods that orchestrate
  • Pure functions that compute
  • Explicit parameters instead of implicit state

This approach makes collaboration smoother, whether your collaborator is a human or an AI.

Final Practical Heuristics (The Ones That Survive Deadlines)

When deadlines are tight, I keep it simple:

  • If it relies on this, make it a method and keep it close to its object.
  • If it doesn’t rely on this, make it a function and export it.
  • If it will be passed as a callback, avoid this or bind explicitly.
  • If it’s business logic, make it pure and test it directly.

These heuristics aren’t perfect, but they’re reliable and easy to teach.

Closing: How I Want You to Think About It

When you’re writing JavaScript, the question isn’t “Is this a method or a function?” The real question is “Where does the data come from?” If the data comes from an object’s internal state, write a method and call it through that object. If the data comes from arguments you pass in, write a function and keep it pure.

In my experience, this mindset leads to cleaner code and fewer surprises. It reduces the odds of losing context when you pass functions around. It makes testing faster because you don’t need to construct elaborate objects just to call simple logic. And it makes refactors safer because the dependencies are explicit.

Here’s the practical next step I recommend: the next time you add a new behavior, pause for ten seconds and decide whether it truly needs object state. If it does, write a method and keep it focused. If it doesn’t, write a function and export it from a module. That one habit will make your codebase clearer, easier to test, and more resilient under change.

Scroll to Top