Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins

Words
2822
Reading
13 min
Listen
Play
20d

Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins

js-banner.png

What will I learn

  • You will learn the full arrow-function syntax, from the verbose form all the way down to the one-liner;
  • the implicit-return rule, and the gotcha that bites everyone the first time they try to return an object literal;
  • the single biggest difference from regular functions: arrows do not have their own this;
  • why that one property makes arrows perfect for callbacks but flat-out wrong for object methods;
  • a clear, practical rule of thumb for choosing between an arrow and a function without agonizing over it.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-15 read, especially first-class functions from last time.

Difficulty

  • Beginner

Curriculum (of the Learn JS Series):

Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins

Solutions to Episode 15 Exercises

Before we start, here are the three worked solutions to last episode's exercises. Type them out, run them, compare them to your own attempts -- that comparison is where the learning actually happens.

Exercise 1 - operations by name:

const mathOps = {
  add: (a, b) => a + b,
  sub: (a, b) => a - b,
  mul: (a, b) => a * b,
};
function compute(a, b, opName) {
  const op = mathOps[opName];
  if (typeof op !== "function") {
    throw new Error(`unknown operation: "${opName}"`);
  }
  return op(a, b);
}
console.log(compute(6, 4, "mul")); // 24

The insight: bracket access picks the function by its string name, then (a, b) calls it -- a lookup table replacing a switch, exactly the dispatch-table idea from episode 15. The typeof guard keeps an unknown name from blowing up with an ugly TypeError.

Exercise 2 - applying a function twice:

function applyTwice(fn, value) {
  return fn(fn(value));
}
console.log(applyTwice((n) => n * 2, 3)); // 12
console.log(applyTwice((s) => s.toUpperCase(), "hi")); // "HI"

The insight: we pass a function in and call it on its own result, composing it with itself. Notice it works for numbers and strings alike -- applyTwice neither knows nor cares what fn does.

Exercise 3 - a greeter factory:

function makeGreeter(greeting) {
  return (name) => `${greeting}, ${name}!`;
}
const hi = makeGreeter("Hi");
const bye = makeGreeter("Bye");
console.log(hi("scipio"), bye("scipio")); // "Hi, scipio!" "Bye, scipio!"

The insight: makeGreeter("Hi") returns a function you keep around and reuse. If you tacked on extra parentheses -- makeGreeter("Hi")() -- you would call the returned greeter immediately (with no name), get one throwaway string, and be left with no reusable greeter to hand out later.

Right -- with that behind us, let's compare the two ways to write a function in JavaScript, because you will read and write both of them constantly, in every codebase you ever touch.

Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins

So far in this series we have written functions the classic way, with the function keyword. In ES2015 (the big 2015 language update, also called ES6) JavaScript gained a second, more compact way to write them: the arrow function. You have already seen me sneak arrows into callbacks like nums.map((n) => n * 2), because they are lighter to read there. But arrows are not just "shorter functions". They carry one deep behavioural difference that quietly decides, in a lot of real code, which of the two you must reach for. This episode takes both apart, side by side, so you know exactly what you are choosing between.

Arrow syntax, from long to short

Arrow functions have a syntax that collapses down in stages, and it is worth walking through those stages deliberately so that the shortest form does not look like line noise later. Here is a regular function value and its direct arrow equivalent, written as verbosely as an arrow can be written:

const addLong = function (a, b) {
  return a + b;
};
const addArrow = (a, b) => {
  return a + b;
};
console.log(addLong(2, 3), addArrow(2, 3)); // 5 5

At this fullest form the two are nearly identical -- parameters in parentheses, a body in braces, an explicit return. The only visible change is that function before the parentheses becomes => after them. So far, arrows have bought us almost nothing. The savings start on the next step.

When the whole body is a single expression, an arrow lets you drop the braces and the return keyword. The value of that one expression becomes the return value automatically. This is called the implicit return, and it is the arrow's signature feature:

const add = (a, b) => a + b;         // implicit return, no braces, no 'return'
const square = (n) => n * n;
const greet = (name) => `Hi, ${name}`;
console.log(add(2, 3), square(4), greet("scipio")); // 5 16 "Hi, scipio"

Read (a, b) => a + b as "given a and b, give back a + b". No braces means "this is an expression, and its result is the return value". The moment you add braces, you are back to a normal block that needs an explicit return -- a very common beginner slip is writing (n) => { n * n } and wondering why it returns undefined (the braces make it a body, and a body with no return gives back undefined). Braces = block = you must return. No braces = expression = returned for you. Keep those two apart in your head.

And when there is exactly one parameter, the parentheses around it become optional (though quite some style guides, and I among them, keep them for consistency):

const double = (n) => n * 2;        // parens around one param (my preference)
const triple = n => n * 3;          // legal too: one param, no parens
const noArgs = () => "constant";    // ZERO params still need the empty ()
console.log(double(21), triple(7), noArgs()); // 42 21 "constant"

Note the asymmetry: one parameter may drop its parentheses, but zero parameters still need an empty () to mark "this is a function of no arguments". Two or more parameters always need parentheses. This stacking of shortcuts -- implicit return plus optional parens -- is exactly why arrows dominate in callbacks. Compare nums.map(function (n) { return n * 2; }) with nums.map((n) => n * 2). Same behaviour, a third of the visual weight, and your eye can take in the intent (double each number) without wading through ceremony.

The object-return gotcha

There is one syntax trap that is worth learning now, up front, because it bites absolutely everyone exactly once and then never again. Suppose you want an arrow to implicitly return an object literal. Your instinct is to write (name) => { name: name }. But those braces are ambiguous -- JavaScript reads a { right after the arrow as the start of a function body, not an object literal. So name: gets parsed as a label (a rarely-used, ancient JS feature), name as a bare expression statement, and the whole thing returns undefined:

// const makeUser = (name) => { name: name }; // BROKEN: {} read as a body -> returns undefined
const makeUser = (name) => ({ name: name });  // wrap the object in () -> returns the object
console.log(makeUser("scipio")); // { name: 'scipio' }

The fix is one character on each side: wrap the object literal in parentheses, => ({ ... }). Now the ( tells the parser "what follows is an expression", the braces are unambiguously an object, and the implicit return does its job. Burn that => ({ ... }) shape into memory, because "my arrow keeps returning undefined when I try to return an object" is one of the most common head-scratchers in the language, and it has a one-character solution.

The real difference: this

Syntax aside, arrows and regular functions differ in one deep, behavioural way -- and this, not the brevity, is the whole reason you actually choose one over the other: arrow functions do not have their own this.

Let me unpack that, because it is the heart of the episode. A regular function gets a this that is decided by how it is called (the full five rules are literally the next episode, so I will keep it light here). An arrow function is different: it has no this of its own at all. When you write this inside an arrow, JavaScript looks outward to the surrounding scope where the arrow was written, and uses that this. This is called lexical this, and it is the exact same "look outward to where the code was written" behaviour as the lexical scope we met back in episode 10 -- just applied to this instead of to variable names.

That sounds abstract, so here is the classic situation where it bites. Imagine an object with a method that hands back an inner function to be called later. With a regular inner function, this inside it is decided by how that inner function eventually gets called -- and that is almost never the object you wanted:

const timer = {
  seconds: 0,
  // a REGULAR inner function loses the connection to 'timer':
  brokenTick: function () {
    return function () {
      // when this inner function is later called on its own,
      // 'this' is NOT 'timer' -- so 'this.seconds' is wrong (or throws)
      this.seconds++;
      return this.seconds;
    };
  },
};

The inner regular function's this gets rebound by whoever calls it later, so this.seconds does not point at timer and the counter breaks. Now watch an arrow fix it cleanly, precisely because the arrow refuses to have its own this and instead borrows the one from the method it was written inside:

const counter = {
  count: 0,
  makeIncrementer() {
    // 'this' here is 'counter' (regular method, called as counter.makeIncrementer())
    return () => {
      this.count++;        // the ARROW borrows makeIncrementer's 'this' = counter
      return this.count;
    };
  },
};
const inc = counter.makeIncrementer();
console.log(inc()); // 1
console.log(inc()); // 2  -- 'this.count' correctly refers to counter
console.log(inc()); // 3

Because the arrow inherited this from makeIncrementer (where this is counter), the increment works even though we call inc() completely detached from the object. This lexical-this behaviour is precisely why arrows became the default for callbacks nested inside methods: they quietly solve the single most infamous this-loss bug in JavaScript's history. Before arrows existed, people worked around it with const self = this; at the top of the method, or with .bind(this) on the inner function (we cover bind in two episodes). Arrows made all of that boilerplate disappear.

Where arrows are WRONG: object methods

Here is the flip side, and it is just as important: the very feature that makes arrows great for callbacks makes them wrong as top-level object methods. If you write an object's method as an arrow, its this is not the object -- it is whatever this was in the scope surrounding the object (at the top level of a module that is undefined or the module scope, in a browser script it is often the global object). So the arrow method cannot reach the object's own properties:

const user = {
  name: "scipio",
  greetBad: () => `Hi, I am ${this.name}`,   // ARROW: 'this' is NOT user
  greetGood() {
    return `Hi, I am ${this.name}`;          // regular method: 'this' IS user
  },
};
console.log(user.greetGood()); // "Hi, I am scipio"
console.log(user.greetBad());  // "Hi, I am undefined" -- arrow has no object 'this'

greetGood is a normal method, so when you call user.greetGood() its this is user, and this.name is "scipio". greetBad is an arrow, so its this was fixed at write-time to the surrounding scope -- which has no name -- and you get undefined. So the guidance splits cleanly and memorably: use a regular function (method shorthand) for object methods that need this, and use an arrow for callbacks nested inside those methods, or anywhere you want to hold on to the surrounding this.

Two more facts: no arguments, no new

While we are cataloguing what arrows lack, two more absences are worth banking, because they occasionally trip people who reach for an arrow out of habit.

First, arrows have no arguments object. In a regular function, arguments is a special array-like value holding every argument that was passed, even ones you did not name. An arrow does not get its own -- like this, a bare arguments inside an arrow refers to the enclosing function's arguments (or is simply not defined at the top level). In modern code this is a non-issue: rest parameters (...args, which we cover next phase) are clearer than arguments anyway, and they work perfectly in arrows:

const sumRegular = function () {
  // 'arguments' exists here: array-like of everything passed
  return Array.from(arguments).reduce((a, b) => a + b, 0);
};
const sumArrow = (...nums) => nums.reduce((a, b) => a + b, 0); // rest params, arrow-friendly
console.log(sumRegular(1, 2, 3), sumArrow(1, 2, 3)); // 6 6

Second, arrows cannot be used with new -- they are not constructors (constructors and the new keyword are a Phase 3 topic). Trying new (() => {})() throws a TypeError. Again, rarely a problem in day-to-day code, but part of the complete picture, and a reason you will never see an arrow used as a class-like factory built with new.

A rule of thumb

You do not need to agonize over this decision on every single function you write. Here is a practical rule that will serve you well for years:

  • Reach for an arrow function by default for short callbacks, array-method transforms (map, filter, reduce), and any helper nested inside a method that needs to keep the outer this.
  • Use a regular function (function or method shorthand) for object methods, for the rare cases that genuinely need their own this or the arguments object, for constructors, and when you specifically want the hoisting behaviour we saw in episode 9 (function declarations are hoisted; arrows assigned to a const are not).

Here are both halves of that rule in action, in the shapes you will actually type:

// arrows shine here -- short transforms, the outer 'this' is irrelevant:
const nums = [1, 2, 3, 4];
console.log(nums.filter((n) => n % 2 === 0).map((n) => n * 10)); // [20, 40]

// a regular method shines here -- it needs 'this' to be the object:
const account = {
  balance: 100,
  deposit(amount) {
    this.balance += amount;   // 'this' must be the account
    return this.balance;
  },
  // ...and an arrow is correct for a callback INSIDE the method:
  applyAll(amounts) {
    amounts.forEach((amt) => {
      this.balance += amt;    // arrow keeps 'this' = account
    });
    return this.balance;
  },
};
console.log(account.deposit(50));       // 150
console.log(account.applyAll([10, 20])); // 180

Look at applyAll: it is a regular method (so this is the account), and inside it the forEach callback is an arrow (so it borrows that same this). That pairing -- regular method on the outside, arrow callback on the inside -- is the single most common and most correct arrangement you will write. Having said that, if you had made the forEach callback a regular function, this.balance inside it would have broken, and you would be back to the bug from earlier. The rule is not arbitrary; it falls straight out of the this behaviour.

How other languages handle this

Many of you came over from the Learn Python Series (and a few from Rust and Go), so a quick look sideways helps place JavaScript's arrows in context -- and shows that the underlying ideas are not JS quirks but broadly shared tools.

Python has a compact function expression too, the lambda, but it is deliberately limited to a single expression -- there is no multi-statement lambda. Python's arrow-equivalent for a one-liner reads very close to JS:

double = lambda n: n * 2
add = lambda a, b: a + b
print(double(21), add(2, 3))   # 42 5

# functions in a dict -- the same dispatch-table idea from episode 15
ops = {"mul": lambda a, b: a * b, "sub": lambda a, b: a - b}
print(ops["mul"](6, 7))        # 42

But note the crucial difference: Python's lambda does not change how self (Python's this) works, because Python passes the receiver explicitly as the first parameter. Python simply never had JavaScript's "this gets rebound by the caller" problem, so it never needed a lexical-this fix. JavaScript's arrow solves a problem that is specific to how JS binds this -- which is exactly why the arrow bundles a this rule in with its syntax, and lambda does not.

Rust has closures with a |params| body syntax that is its arrow analogue, and, like arrows, a closure captures variables from the scope where it was written:

fn main() {
    let factor = 3;
    let triple = |n: i32| n * factor;  // closure captures 'factor' lexically
    println!("{}", triple(5));          // 15
}

The lexical capture matches the spirit of the arrow's lexical this -- both look outward to where the code was written. Rust just makes the capture rules explicit and checked by the compiler, where JavaScript keeps it loose and implicit. Same idea, different amount of paperwork -- the recurring theme every time we hold these languages up next to each other. ;-)

Go uses func(params) returnType { ... } for its function literals, which also close over surrounding variables:

package main

import "fmt"

func main() {
    factor := 3
    triple := func(n int) int { return n * factor } // closes over 'factor'
    fmt.Println(triple(5))                            // 15
}

Go has no separate short-arrow form and no this-rebinding problem (methods take an explicit receiver, much like Python), so a Go function literal is closest in feel to a JavaScript arrow that is being used purely for its brevity and its lexical capture -- minus the this machinery. The throughline across all four: a compact function expression that captures its surrounding scope. JavaScript is the odd one that also folds a this rule into that same syntax, because JS's this needed the fix and the others' did not.

Try it yourself

Three exercises, increasing in difficulty. Type them out and run them -- reading is genuinely not the same as knowing. Full solutions open the next episode.

  1. Convert these three regular functions to the shortest valid arrow form, then print a call to each: function (x) { return x + 1; }, function (a, b) { return a * b; }, and function () { return "hello"; }. (Watch the zero-parameter one -- it still needs ().)
  2. Write an arrow makePoint(x, y) that returns the object { x, y } using the implicit-return syntax. Then, in a comment, show the BROKEN version without the wrapping parentheses and explain in one sentence why it returns undefined.
  3. Create an object stopwatch with a laps array and a record() method written as a regular function. Inside record(), use [10, 20, 30].forEach(...) with an arrow callback that pushes each value onto this.laps. Confirm the arrow can see the object's this, and then, in a comment, describe what would break if you rewrote that callback as a regular function.

So what did we actually cover?

  • Arrow syntax collapses in stages: full body with braces and return, then implicit return for a single expression, then optional parentheses for a single parameter (but zero params always need ()).
  • To implicitly return an object literal, wrap it in parentheses: => ({ ... }), or the braces get read as a function body.
  • The deep difference: arrows have no this of their own -- they use the surrounding (lexical) this, while regular functions get a this decided by how they are called (full rules next episode).
  • That makes arrows ideal for callbacks inside methods (they keep the object's this) but wrong as object methods themselves (their this is not the object).
  • Arrows also have no arguments object and cannot be used with new -- reach for rest parameters and regular functions respectively when you need those.
  • Rule of thumb: arrows for callbacks and transforms, regular functions for object methods and cases needing their own this, arguments, new, or hoisting.
  • Python, Rust, and Go all have compact function expressions with lexical capture; JavaScript is unusual in bundling a this rule into the syntax, because JS's this is the one that needed fixing.

Next episode is the big one -- the single most important idea in the whole language, the mechanism that has been quietly powering every factory and wrapper we have written since episode 15. We take it fully apart, with real, practical examples of the private state it makes possible.

See you in the next episode.

scipio@scipio