bastianplsfix

Functions

JavaScript has one function entity that plays three roles, and several specialized ones that each play exactly one. An ordinary function, from a function declaration or a function expression, can be called as a function, called as a method, or called with new. Three jobs, one thing. The specialized forms each pick a job and decline the rest: an arrow function is only ever a real function, a method is only ever a method, a class is only ever a constructor. All of them are still functions, in the sense that matters to instanceof.

The reason to care is not the taxonomy. Each specialization turns a category of mistake into an error at the point where you make it, and TypeScript takes away one more role on top of what the language does.

Create programs/functions.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

two syntaxes, and one difference that shows

Both syntaxes make an ordinary function, and the difference is when the name becomes usable. Call each one above its own definition:

Deno.test("two syntaxes, and one difference that shows", () => {
assertStrictEquals(declared(2), 4);

assertStrictEquals(expressed(2), 4);

const expressed = function (n: number): number {
return n * 2;
};
assertStrictEquals(expressed(2), 4);

function declared(n: number): number {
return n * 2;
}
});
Check programs/functions.test.ts
TS2448 [ERROR]: Block-scoped variable 'expressed' used before its declaration.
assertStrictEquals(expressed(2), 4);
~~~~~~~~~
at file:///programs/functions.test.ts:12:24

'expressed' is declared here.
const expressed = function (n: number): number {
~~~~~~~~~
at file:///programs/functions.test.ts:14:11

TS2454 [ERROR]: Variable 'expressed' is used before being assigned.
assertStrictEquals(expressed(2), 4);
~~~~~~~~~
at file:///programs/functions.test.ts:12:24

Found 2 errors.

The early call to declared drew no complaint, and the early call to expressed drew two. A function declaration is activated early, so calling it above its own definition works, which is why declared can sit at the bottom of the step. A const holding a function expression is a const like any other, so reading it early lands in the temporal dead zone from the scope and declarations page's the temporal dead zone, and the checker refuses at compile time with the pair above.

To watch the language enforce the same rule at run time, the early read has to hide from the checker inside a callback, which the checker must assume runs later:

Deno.test("two syntaxes, and one difference that shows", () => {
assertStrictEquals(declared(2), 4);

assertThrows(
() => expressed(2),
ReferenceError,
"Cannot access 'expressed' before initialization",
);

const expressed = function (n: number): number {
return n * 2;
};
assertStrictEquals(expressed(2), 4);

function declared(n: number): number {
return n * 2;
}
});
Check programs/functions.test.ts
running 1 test from ./programs/functions.test.ts
two syntaxes, and one difference that shows ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

The rule is real in both worlds; only the moment you learn about it moves. Written bare, it is TS2448 before the program runs. Deferred into a callback, it is a ReferenceError while it runs, with the same "before initialization" message the scope page pinned.

the three roles of an ordinary function

Deno.test("the three roles of an ordinary function", () => {
function add(a: number, b: number): number {
return a + b;
}

assertStrictEquals(add(2, 1), 3);

const calculator = { add };
assertStrictEquals(calculator.add(2, 4), 6);

// @ts-expect-error: 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.
const instance = new add(2, 1);
assert(instance instanceof add);
});
the three roles of an ordinary function ... ok (0ms)

The same add, doing all three jobs: a real function on the first call, a method once it sits on calculator, and a constructor under new. Note the shield on the third. At run time new add(2, 1) really does build an object, which the instanceof proves, and TypeScript refuses the expression with TS7009, because a plain function declaration has no construct signature.

That is worth stating plainly: TypeScript has already removed the third role. In a checked codebase an ordinary function has two jobs, not three, and the capital-letter convention for constructor functions is a historical note rather than something you have to remember.

each specialized form refuses what it is not

Deno.test("each specialized form refuses what it is not", () => {
const arrow = (n: number): number => n + 1;
assertStrictEquals(arrow(1), 2);
assertThrows(
// @ts-expect-error: 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.
() => new arrow(1),
TypeError,
"arrow is not a constructor",
);

class Point {
x: number;
constructor(x: number) {
this.x = x;
}
}
assertStrictEquals(new Point(2).x, 2);
assertThrows(
// @ts-expect-error: Value of type 'typeof Point' is not callable. Did you mean to include 'new'?
() => Point(1),
TypeError,
"Class constructor Point cannot be invoked without 'new'",
);

const holder = {
method(): number {
return 1;
},
};
assertStrictEquals(holder.method(), 1);
assertThrows(
// @ts-expect-error: 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.
() => new holder.method(),
TypeError,
"holder.method is not a constructor",
);
});
each specialized form refuses what it is not ... ok (0ms)

Three specialized forms, and each declines a role. An arrow function is not a constructor, a class cannot be called without new, and a method defined with shorthand syntax is not a constructor either. In every case the checker complains first, in the pinned comments, and the runtime complains second, in the pinned messages, which is the pattern worth expecting: specialization means the wrong use has somewhere to fail. TypeScript's message for the class is the most helpful of the set, because it guesses what you meant: "Did you mean to include 'new'?".

they are all functions

Deno.test("they are all functions", () => {
const arrow = (n: number): number => n + 1;
const holder = {
method(): number {
return 1;
},
};
class Point {}
function declared(): void {}

assert(arrow instanceof Function);
assert(holder.method instanceof Function);
assert(Point instanceof Function);
assert(declared instanceof Function);
});
they are all functions ... ok (0ms)

Including the class. A class is a constructor function with a restricted calling convention, not a separate kind of thing, which is why it can be passed around, stored in an array, and returned from a function like any other value. The refusals in the previous step are policies attached to one shared kind of value, not evidence of different kinds.

a function without a return still returns

Deno.test("a function without a return still returns", () => {
const ran: string[] = [];
function noReturn() {
ran.push("ran");
}

assertStrictEquals(noReturn(), undefined);
assertEquals(ran, ["ran"]);
});
a function without a return still returns ... ok (0ms)

The body ran, the log proves it, and the call still produced a value: undefined. A function that reaches its end without a return gives back undefined, so there is no such thing as a function that returns nothing. What undefined means here is the nothing, twice page's subject, and the checker-side distinction between void and never sits on the any, unknown, and never page next to a function typed never cannot return.

three arrow bodies

Deno.test("three arrow bodies", () => {
const block = (n: number): number => {
return n + 1;
};
const expression = (n: number): number => n + 1;

for (const n of [0, 1, 2]) {
assertStrictEquals(block(n), expression(n));
}
});
three arrow bodies ... ok (0ms)

A block body with an explicit return, and an expression body that returns implicitly: the loop proves they are the same function. The third spelling drops the parentheses around a single identifier parameter, n => n + 1, and it cannot appear in this file, because deno fmt puts the parentheses back on every save. That is a small kindness: the formatter removes the choice from code review, and one less thing has an opinion attached.

an object literal in an arrow body needs parentheses

Two arrows that both mean to return { a: 1 }. The deno-fmt-ignore keeps the formatter from touching the second one's spelling, and the prediction treats them as equivalent:

Deno.test("an object literal in an arrow body needs parentheses", () => {
const wrapped = (): { a: number } => ({ a: 1 });
// deno-fmt-ignore
const bare = () => {a: 1};

assertEquals(wrapped(), { a: 1 });
assertEquals(bare() as unknown, { a: 1 });
});
Check programs/functions.test.ts
running 7 tests from ./programs/functions.test.ts
...
an object literal in an arrow body needs parentheses ... FAILED (8ms)

ERRORS

an object literal in an arrow body needs parentheses => ./programs/functions.test.ts:118:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

+ {
+ a: 1,
+ }
- undefined

FAILURES

an object literal in an arrow body needs parentheses => ./programs/functions.test.ts:118:11

FAILED | 6 passed | 1 failed (10ms)

error: Test failed

bare returned undefined, and the checker said nothing on the way to that failure, because bare is inferred as returning void, which is consistent with what it actually does. What it actually does is the surprise. Run deno lint on the file as it stands and the linter names the problem exactly:

error[no-unused-labels]: `a` label is never used
--> programs/functions.test.ts:121:25
|
121 | const bare = () => {a: 1};
| ^^^^

docs: https://docs.deno.com/lint/rules/no-unused-labels

A label. Braces after an arrow are read as a block, so {a: 1} is a block containing the label a: and the expression statement 1, and a block body with no return produces undefined. This is the same no-unused-labels that reported the leftover on the loops page in a labelled block gives failure exactly one place, except this time the label is one you did not know you wrote. The ambiguity is real, because object literals and blocks are spelled the same way, and the arrow is where the overlap bites.

The parentheses in wrapped say "this is an expression, not a statement". Correct the prediction to undefined, and sign the lint ignore to keep the exhibit:

Deno.test("an object literal in an arrow body needs parentheses", () => {
const wrapped = (): { a: number } => ({ a: 1 });
// deno-fmt-ignore
// deno-lint-ignore no-unused-labels
const bare = () => {a: 1};

assertEquals(wrapped(), { a: 1 });
assertStrictEquals(bare() as unknown, undefined);
});
an object literal in an arrow body needs parentheses ... ok (1ms)

One more fact worth the space: annotate bare's return type as { a: number } and the checker catches the mistake immediately, because void no longer matches what you wrote down. The trap is silent only when the boundary is unannotated, which is one more argument for annotating boundaries.

where a name comes from

Deno.test("where a name comes from", () => {
function declared(): void {}
const assigned = function (): void {};
const selfNamed = function chosen(): void {};

assertStrictEquals(declared.name, "declared");
assertStrictEquals(assigned.name, "assigned");
assertStrictEquals(selfNamed.name, "chosen");
assertStrictEquals((function () {}).name, "");
assertStrictEquals((() => {}).name, "");

function failing(): never {
throw new Error("no");
}
const error = assertThrows(() => failing(), Error, "no");
assert((error.stack ?? "").includes("at failing"));
});
where a name comes from ... ok (0ms)

A function's .name comes from whatever named it. A declaration supplies one. Assigning an anonymous function to a variable supplies one, which is why an arrow stored in a const is not anonymous. A named function expression supplies its own, and chosen wins over selfNamed because the expression's name is more specific than the variable's. An anonymous function passed straight into a call has nothing to draw on and gets the empty string.

The reason to care is the last assertion: names are what a stack trace has to work with, and at failing appears in the trace because failing had a name to offer. Deeply nested anonymous callbacks produce traces full of nothing, which is the practical argument for naming a callback that is more than a line long. Stack traces themselves are measured on the errors and exceptions page in the stack.

a named function expression can reach itself

Deno.test("a named function expression can reach itself", () => {
const recursive = function itself(): unknown {
return itself;
};

assertStrictEquals(recursive(), recursive);

assertThrows(
() => {
// @ts-expect-error: Cannot find name 'itself'.
itself();
},
ReferenceError,
"itself is not defined",
);
});
a named function expression can reach itself ... ok (0ms)

The first assertion proves the inner name works: calling recursive returns itself, and itself is the very same function object. The second proves the name exists only inside the body: outside it, the checker cannot find the name, and the runtime agrees with itself is not defined. The inner name lets a function recurse without depending on the variable it was assigned to, which matters if that variable is later reassigned, or if the function is passed somewhere and called by another name. A small feature, genuinely useful twice: for that independence, and for giving a stack-trace name to a callback that would otherwise be anonymous.

a function built at run time

Deno.test("a function built at run time", () => {
const times = new Function("a", "b", "return a * b") as (
a: number,
b: number,
) => number;

assertStrictEquals(times(3, 4), 12);
assertStrictEquals(times.name, "anonymous");

const myVariable = "outer";
assertStrictEquals(myVariable, "outer");

// deno-lint-ignore no-eval
assertStrictEquals(eval("myVariable"), "outer");

// deno-lint-ignore no-eval
const indirect = eval;
assertStrictEquals(indirect("typeof myVariable"), "string");
});
Check programs/functions.test.ts
running 10 tests from ./programs/functions.test.ts
...
a function built at run time ... FAILED (8ms)

ERRORS

a function built at run time => ./programs/functions.test.ts:163:11
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- undefined
+ string

FAILURES

a function built at run time => ./programs/functions.test.ts:163:11

FAILED | 9 passed | 1 failed (11ms)

error: Test failed

The direct eval("myVariable") saw the local variable and answered "outer", so the prediction assumed indirect would see it too and answer "string" for its typeof. It answered "undefined": called directly as eval(...), it evaluates in the current scope, and reached any other way, through a variable, a property, optional chaining, or .call, it evaluates in the global scope, where no myVariable exists. The same function, two behaviors, selected by the shape of the call site. Correct the prediction to "undefined":

a function built at run time ... ok (0ms)

The step is littered with signatures, and each one is a warning. new Function takes parameter names and a body as strings and produces a real function named "anonymous", and the cast on it is the claim from the any, unknown, and never page's as is a claim, not a conversion: the result is typed Function, so nothing about the parameters or the return is checked, and you are outside the type system for as long as the value stays in scope. The two deno-lint-ignore no-eval lines are Deno pushing back on eval by default. And the odd-looking assertStrictEquals(myVariable, "outer") exists because without it the linter reports myVariable as never used: the only other reader is an eval string, and no tool in the pipeline can see into a string, which is the whole problem with both of these functions in one lint finding.

Both are worth knowing about and avoiding. They are a security problem whenever any part of the string came from outside, and the dynamic thing you wanted is nearly always available without them: obj[key] rather than eval("obj." + key). new Function is the lesser evil, because it always evaluates in global scope and its parameters give the generated code a defined interface. This series uses it only where compiling a string is the one way to observe a SyntaxError on purpose: on the branching, parameters and arguments, private class members, destructuring, generators, async functions, and async iteration pages.

an arrow is the right shape for a callback

Deno.test("an arrow is the right shape for a callback", () => {
assertEquals([1, 2, 3].map((n) => n * 2), [2, 4, 6]);
});
an arrow is the right shape for a callback ... ok (0ms)

Shorter, and with no this of its own, which is the subject of its own page, the value of this, and the real reason the preference is not just about typing fewer characters. For a standalone named function, a declaration is still fine: early activation is occasionally useful, the syntax is clear, and if this never appears in the body there is nothing to gain from an arrow. The habit worth keeping is consistency within a file.

In practice