Choosing the right JavaScript function
_Ever feel like there are so many ways to write a function in JavaScript? I've come up with a few simple rules that help me choose the right one.
There are so many ways to declare a function in JavaScript, each with its own behavior and quirks. This often leads to confusion, and a lot of debates about which approach is best that most of the time ends up with "let's agree to disagree".
In this post, we'll break down the key differences between arrow functions, methods, and regular functions.
We'll cover syntax, memory efficiency via the prototype, behavior of this
, generator support, typing advantages in TypeScript, and end with my approach to when to use each.
Syntax
Arrow Function
const add = (a, b) => {
return a + b
}
Arrow Function Shorthand
const add = (a, b) => a + b
Regular Function
function add(a, b) {
return a + b
}
You can also assign regular functions to variables:
const add = function(a, b) {
return a + b
}
Object Method
const op = {
add(a, b) {
return a + b
}
}
You can also assign arrow functions and regular functions as properties to objects:
const op = {
add: (a, b) => a + b,
subtract: function (a, b) {
return a - b
}
}
Class Method
class Op {
add(a, b) {
return a + b
}
}
You can also assign arrow functions and regular functions as properties to classes, we'll cover this in more detail later:
class Op {
add = (a, b) => a + b,
subtract = function (a, b) {
return a - b
}
}
Special functions
There are also special functions like getters, setters and static methods:
class Op {
// Getter
get add() {
return this.a + Op.b()
}
// Setter
set a(value) {
this.a = value
}
// Static method
static b() {
return 5
}
}
console.log(Op.b()) // 5
const op = new Op()
op.a = 10
console.log(op.add) // 15
Prototype and Memory Efficiency
In JavaScript, methods are stored on the prototype, which makes them memory-efficient. All instances of the class share the same function reference.
class Op {
add(a, b) {
return a + b
}
}
const op1 = new Op()
const op2 = new Op()
console.log(op1.add === op2.add) // true
console.log(Op.prototype.add === op1.add) // true
However, if you define an arrow function as a class property, a new function is created for each instance:
class Op {
add = (a, b) => a + b
subtract = function (a, b) {
return a - b
}
}
const op1 = new Op()
const op2 = new Op()
console.log(op1.add === op2.add) // false
console.log(Op.prototype.add) // undefined
console.log(op1.subtract === op2.subtract) // false
console.log(Op.prototype.subtract) // undefined
Getters and setters are also stored on the prototype:
class Op {
a = 5
b = 10
get add() {
return this.a + this.b
}
set a(value) {
this.a = value
}
}
console.log(Object.getOwnPropertyDescriptor(Op.prototype, 'add')) // { get: [Function: get add], set: undefined, enumerable: false, configurable: true }
console.log(Object.getOwnPropertyDescriptor(Op.prototype, 'a')) // { get: undefined, set: [Function: set a], enumerable: false, configurable: true }
Read more about the prototype in the MDN documentation.
this
Binding
Regular and Method Functions
Regular functions and methods use dynamic this
. It depends on how the function is called:
class Op {
a = 5
add(b) {
return this.a + b
}
}
const op = new Op()
console.log(op.add(5)) // ✅ 10
const cb = op.add
console.log(cb(5)) // ❌ Cannot read properties of undefined (this.a)
To fix this, use .bind()
, which creates a new function with this
bound to the specified object:
const bound = op.add.bind(op)
console.log(bound(5)) // ✅ 10
Arrow Functions
Arrow functions use lexical this
, meaning they capture this
from the surrounding context at creation time:
class Op {
a = 5
add = (b) => this.a + b
}
const op = new Op()
const cb = op.add
console.log(cb(5)) // ✅ 10
Read more about .bind()
in the MDN documentation.
Hoisting
One key difference is hoisting. Regular function declarations are hoisted, meaning you can call them before they're defined in the source code:
console.log(add(2, 3)) // ✅ 5
function add(a, b) {
return a + b
}
Arrow functions and function expressions are not hoisted. Accessing them before definition will throw a ReferenceError
:
console.log(add(2, 3)) // ❌ ReferenceError
const add = (a, b) => a + b
This is an important advatage of regular functions as it allows you to define the functions in a more logical order.
Read more about hoisting in the MDN documentation.
Generator Support
Arrow functions cannot be used as generators or async generators. Only function declarations and methods can:
class Op {
a = 5; // This semi-colon is important! 🫠
*range(b) {
for (let i = this.a; i < b; i++) {
yield i
}
}
}
const op = new Op()
for (const x of op.range(10)) {
console.log(x) // 5, 6, 7, 8, 9
}
Or with regular function syntax:
function* range(a, b) {
for (let i = a; i < b; i++) {
yield i
}
}
Read more about generators in the MDN documentation.
TypeScript Typing
Regular function declarations can't be typed directly in TypeScript. You can only enforce the type using a function expression or an arrow function:
type Foo = () => string
const foo: Foo = function (): string {
return 'text'
}
const foo: Foo = () => 'text'
This trick doesn't work for methods. The only way to enforce the type of a class method is by using an interface:
type Foo = () => string
interface MyInterface {
foo: Foo
}
class MyClass implements MyInterface {
foo(): string {
return 'text'
}
}
Summary
Feature | Regular Function | Arrow Function | Method |
---|---|---|---|
Inline Syntax | ✅ | ✅✅ (shorthand) | N/A |
Prototype Memory Sharing | ❌ | ❌ | ✅ |
this Behavior |
Dynamic | Lexical (safe) | Dynamic |
Hoisting | ✅ | ❌ | N/A |
Can Be Generator | ✅ | ❌ | ✅ |
Function Type | ✅ | ✅ | ❌✅ (not directly) |
Conclusion: When to Use What?
Here's my personal approach:
- Use regular functions for top-level code. They are hoisted, and they support generators. You probably don't use
this
here, so arrow functions don't add any benefit. - Use methods whenever possible (except a few niche cases), because it is memory-efficient, has generators and the shortest syntax.
- For inline callbacks (e.g.
setTimeout
,map
, etc.), I use arrow functions. - When I want to enforce the function type in TypeScript, I use arrow functions.
- For simple objects it is very unlikely that you will use
this
or the memory benefits of the prototype, so arrow functions are a good fit. - When needing to preserve
this
in a callback, I don't have a strong preference. I usually use arrow functions, but methods with.bind()
are also a good option.
Arrow functions are powerful tools, but they’re not always the right fit. Understand the tradeoffs, and choose the best tool for the job.