"Arrow functions and declared functions are not stylistic variants. They make different decisions about this, and that decision is permanent."
The defining difference
A function declaration's this is dynamic. The value of this is determined by how the function is called: as a method (obj.fn() → this is obj), as a plain function (fn() → this is undefined in strict mode), via fn.call(other) (→ this is other), or via new fn() (→ this is the new instance).
An arrow function's this is lexical. It captures this from the surrounding scope at the moment of definition. Once captured, it never changes — calling .call(other) on an arrow function does nothing to this.
That's the whole rule. Every other arrow-vs-declared discussion is downstream of it.
When to reach for each
Arrow functions: callbacks, event handlers inside class methods, inline helpers, anywhere you want this to mean what it meant at the line you wrote. The 90%+ case in modern TypeScript.
Function declarations / expressions: object methods where this is supposed to be the object, class methods (which already get this-binding semantics automatically), constructors, and generator functions. Plus the rare case where you genuinely want this to be rebindable.
The class-method gotcha
Classes mix both. Class methods are function declarations under the hood, so their this is dynamic. That means passing obj.method as a callback loses this:
class Counter {
count = 0;
increment() { this.count++ }
}
const c = new Counter();
setTimeout(c.increment, 1000); // ❌ `this` is undefined when called
setTimeout(() => c.increment(), 1000); // ✅ arrow preserves `c`
The two common fixes: wrap the call in an arrow (above), or define the method as an arrow-function field (increment = () => { this.count++ } — captures this at construction time). The second has a cost — a new function per instance instead of one on the prototype — but it eliminates the binding issue.