Skip to content

refactor: retire function-keyword in favor of arrow/method syntax - #3312

Open
kriskowal wants to merge 19 commits into
masterfrom
chore/retire-function-keyword
Open

refactor: retire function-keyword in favor of arrow/method syntax#3312
kriskowal wants to merge 19 commits into
masterfrom
chore/retire-function-keyword

Conversation

@kriskowal

Copy link
Copy Markdown
Member

Summary

Retires function-keyword functions in favor of arrow and concise-method syntax across package sources, except for a documented set of legitimate-exception categories. Adds docs/house-style/function-keyword.md capturing the rule and rationale, and links it from AGENTS.md.

The motivating hardened-JS hazards of function-keyword functions:

  1. They have both [[Construct]] and [[Call]] behaviors (callable with new).
  2. They carry an initial prototype property pointing at an irrelevant object.
  3. Because of that extra object, freeze is not equivalent to harden, leaving hazardous mutability behind.
  4. Function-keyword declarations additionally have hoisting hazards in import cycles (TDZ-skipping).

Arrow functions and concise methods have none of these; they are the desired default. Exceptions are documented and explained.

Ferried from the bot fork: endojs/endo-but-for-bots#474, rebased onto current master. Originated from erights's review on endojs/endo-but-for-bots#468.

Per-package conversions

~93 conversion sites across 17 packages: promise-kit, harden, eventual-send, trampoline, import-bundle, cli, benchmark, evasive-transform, init, bundle-source, zip (21), ocapn/syrup (19), module-source, compartment-mapper, daemon, eslint-plugin (8), and low-risk helpers in ses (11). See docs/house-style/function-keyword.md for the full rule.

Legitimate exceptions kept (see docs/house-style/function-keyword.md)

  • Constructor emulation: baseHandledPromise (eventual-send), NewCompartment / Compartment (import-bundle, ses), the SES inert-constructor pattern, ModuleSource / AbstractModuleSource.
  • Generator / async-generator expressions: no arrow spelling exists; preserved in trampoline, captp, compartment-mapper, daemon, stream, syrup-frame, netstring, module-source/src-xs.
  • Vendored / third-party-derived code: cjs-module-analyzer (port of es-module-lexer, mutual recursion via hoisting), test262-runner/test262.
  • Sloppy-mode this detection: function getThis() { return this; } in ses/src/assert-sloppy-mode.js.
  • TypeScript assertion functions: function assertX(...): asserts x is Y in compartment-mapper/src/compartment-map.js.
  • Module-init forward references: convertValToSlot/convertSlotToVal (captp), serializeAndSendMessage (ocapn client), safeRequire (eslint-plugin) — referenced before declaration during module init; restructuring is out of scope here.
  • Bundler runtime template literals: observeImports / wrapCjsFunctor text inside compartment-mapper's bundle-mjs.js / bundle-cjs.js runtime strings (that's emitted output, not module-side code).

Ferry note: immutable-arraybuffer deferred

The bot-fork PR also converted a couple of sites in packages/immutable-arraybuffer/src/lib.js, but those sit inside the freezable-TypedArray emulation block that is still in flight upstream as #3311. That hunk is intentionally omitted from this PR and should ride with #3311 (or a trivial follow-up) once it lands. No other content was dropped.

Categories flagged for review

  1. SES tame* / enable* / permits-intrinsics / lockdown-path declarations (~45 sites remaining in SES) — deferred for a per-file follow-up audit since each touches the security boundary.
  2. Module-init forward references in captp, ocapn client, and assert-fail-as-throw.js — convertible with a file reorder, which warrants a separate decision.
  3. The cjs-module-analyzer port of es-module-lexer (~38 sites) — single-pass lexer mutual recursion leaning on hoisting; treated as vendored to avoid a hot-path regression.

Test results

yarn lint and per-package test suites pass on top of current master, including: compartment-mapper 902, ocapn 260, ses 510, eslint-plugin 137, module-source 57, evasive-transform 52, bundle-source 39, eventual-send 33, harden 32, import-bundle 16, trampoline 11, promise-kit 7, init 5, zip 2.

Refs

Continues from feedback in endojs/endo-but-for-bots#468 and #474.

@changeset-bot

changeset-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 81e6225

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@kriskowal
kriskowal requested a review from erights June 23, 2026 15:36
@kriskowal
kriskowal force-pushed the chore/retire-function-keyword branch from 8dfde6e to aaad5c7 Compare June 25, 2026 17:01
Comment thread docs/house-style/function-keyword.md Outdated
Comment on lines +18 to +22
4. Function-keyword declarations additionally have hoisting hazards.
A function declaration is hoisted and fully initialized before the module
body runs — it has no temporal dead zone — so in an import cycle one side
can observe the function as a value before the rest of the module has run,
masking initialization-order bugs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
4. Function-keyword declarations additionally have hoisting hazards.
A function declaration is hoisted and fully initialized before the module
body runs — it has no temporal dead zone — so in an import cycle one side
can observe the function as a value before the rest of the module has run,
masking initialization-order bugs.
4. Function-keyword declarations additionally have hoisting hazards.
A function declaration `f` is hoisted and fully initialized before the
module body runs — it has no temporal dead zone — masking
initialization-order bugs. For example, a `harden(f)` or `freeze(f)`
immediately after the function-keyword declaration of `f` does not
prevent other code from mutating `f` before it is frozen.
- This hazard exists even among code within one module, though
these are less urgent because the eslint `no-use-before-define`
rule reliably flags these hazards.
- For an `export function f` exported function-keyword function
declaration `f` in an import cycle, the importer can observe the
function as a value before the rest of the exporting module has run.
This case cannot be reliably avoided by other means.

Comment thread docs/house-style/function-keyword.md Outdated
Comment on lines +24 to +30
The arrow function `() => {}` form has none of these hazards: no
`[[Construct]]`, no `prototype`, no early initialization (a `const` binding
stays in its temporal dead zone until evaluated), and `freeze` is equivalent to
`harden`.
Concise-method syntax (`{ name() {} }`, `{ get name() {} }`,
`{ set name(v) {} }`) likewise has no `[[Construct]]` and no `prototype`,
while still binding `this`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The arrow function `() => {}` form has none of these hazards: no
`[[Construct]]`, no `prototype`, no early initialization (a `const` binding
stays in its temporal dead zone until evaluated), and `freeze` is equivalent to
`harden`.
Concise-method syntax (`{ name() {} }`, `{ get name() {} }`,
`{ set name(v) {} }`) likewise has no `[[Construct]]` and no `prototype`,
while still binding `this`.
The arrow function `() => {}` form has none of these hazards: no
`[[Construct]]`, no `prototype`, no early initialization (a `const` binding
stays in its temporal dead zone until evaluated), and `freeze` is equivalent to
`harden`. An arrow function lexically binds `this`, meaning that it is insensitive
to the `this`-binding provided by its callers.
Concise-method syntax (`{ name() {} }`, `{ get name() {} }`,
`{ set name(v) {} }`) likewise has no `[[Construct]]` and no `prototype`,
while being sensitive to the `this`-binding provided by its callers.

Comment thread docs/house-style/function-keyword.md Outdated

ECMAScript provides no arrow-function spelling for generators or
async-generators.
A `function*` or `async function*` expression is the only way to write one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not true. These could be written with concise method syntax. Unless there is a separate reason for using function-keyword functions, we should prefer concise method syntax for generators and async generators.

Comment thread docs/house-style/function-keyword.md Outdated
Comment on lines +127 to +129
Arrow functions and concise methods bind `this` lexically, which would make
`getThis()` return the module-scope `this` (always `undefined` under modules),
defeating the check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arrow functions bind this lexically, meaning they are insensitive to the this binding provided by their callers. Both function-keyword functions and concise methods do not, meaning they are sensitive to the this binding provided by their callers in the same way. I believe this is just as true for sloppy mode though I have not checked. If so, we should still prefer concise methods for this case.

Comment thread docs/house-style/function-keyword.md Outdated
Comment on lines +133 to +140
`function assertX(...): asserts x is Y` requires a function declaration under
the current TypeScript checker; converting to an arrow drops the `asserts`
narrowing and the compiler emits TS2775 ("Assertions require every name in the
call target to be declared with an explicit type annotation").
Where the function is an assertion, the declaration stays.
Concrete site:
`packages/compartment-mapper/src/compartment-map.js`:
`function assertModuleConfiguration`.

@erights erights Jun 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yuck. I don't even understand this. But if in all of endo these are the only exceptions, maybe I don't need to.

I will note that I hate to introduce actual runtime hazards in order to workaround a weakness of our static checking tools. In general, I prefer an @ts-expect-error or @ts-ignore to suppress the weakness of static checking tools in order to preserve less hazardous runtime behavior, which is what counts.

@gibson042 gibson042 Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the claim either, but I think I have a counterexample.

Comment on lines +20 to +21
// Named so stack traces and `.name` keep reporting `postpone` for
// postponed operations, without reintroducing the `function` keyword.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SES also has a defineName helper for this purpose that should probably be made available in @endo/common.

* @typedef {object} AsyncLocalStorageInternal
* @property {boolean} enabled
* @property {typeof _propagate} _propagate
* @property {(resource: object, triggerResource: object, type?: string) => void} _propagate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is just a drive-by, fine, I don't need to understand it.

OTOH, if it is somehow entangled with the purpose of this PR, I'd like to first understand how before approving.

Comment on lines +104 to +107
AsyncLocalStorage.prototype._propagate = patches._propagate;
AsyncLocalStorage.prototype.enterWith = patches.enterWith;
AsyncLocalStorage.prototype.run = patches.run;
AsyncLocalStorage.prototype.getStore = patches.getStore;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having done the (very nice!) refactoring of bundling all these concise methods as the methods of one patches object, I wonder if these four lines should instead be

Suggested change
AsyncLocalStorage.prototype._propagate = patches._propagate;
AsyncLocalStorage.prototype.enterWith = patches.enterWith;
AsyncLocalStorage.prototype.run = patches.run;
AsyncLocalStorage.prototype.getStore = patches.getStore;
defineProperty(AsyncLocalStorage.prototype, getOwnPropertyDescriptors(patches));

?

If, for some bizarre reason, we actually prefer assignment semantics here over descriptor preservation, should they be

Suggested change
AsyncLocalStorage.prototype._propagate = patches._propagate;
AsyncLocalStorage.prototype.enterWith = patches.enterWith;
AsyncLocalStorage.prototype.run = patches.run;
AsyncLocalStorage.prototype.getStore = patches.getStore;
Object.assign(AsyncLocalStorage.prototype, patches);

?

Comment thread docs/house-style/function-keyword.md Outdated
The author must remember to harden the wrapping closure, not just freeze it.
We accept this trade-off because the alternative is no generator at all.

Examples kept under this exception:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor cases are obvious enough that documenting these in this file alone is sufficient, especially if the identifiers are initial-caps. For the others, please put in a comment at least just saying "See" this function-keyword.md file.

Comment thread docs/house-style/function-keyword.md Outdated
emulates a built-in TypedArray constructor (uses `new.target`,
`construct(...)`, and exposes `prototype`).
- `packages/eventual-send/src/handled-promise.js`:
`function baseHandledPromise`, which the author already documented as

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be

Suggested change
`function baseHandledPromise`, which the author already documented as
`function BaseHandledPromise`, which the author already documented as

?

As of this PR, it is especially important to maintain the constructor-names-are-initial-caps rule because the difference is even more important.

@erights erights left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also notice there is no changeset. Is this because it is a pure refactor with no behavioral change for correct clients? But there's still the potential breakage for "incorrect" clients.

kriscendobot pushed a commit to endojs/endo-but-for-bots that referenced this pull request Jun 26, 2026
Apply Mark Miller's CHANGES_REQUESTED review (endojs/endo#3312 review
4575447499, mirrored as #474):

- Rewrite the hoisting-hazards and arrow/concise-method this-binding
  rationale per his suggestion blocks (no TDZ on declarations; harden/freeze
  after a declaration does not prevent earlier mutation; arrow lexical this vs
  caller-this-sensitive concise methods).
- Recommend concise-method generators/async generators by default; correct the
  false claim that function*/async function* is the only spelling.
- Correct the sloppy-mode this-detection note: concise methods are
  caller-this-sensitive, not lexical.
- Replace the function-keyword TypeScript-assertion exception: convert
  assertModuleConfiguration to an arrow (less-hazardous runtime) carrying the
  asserts narrowing via JSDoc @overload, so neither the function keyword nor a
  @ts-expect-error is needed; document preferring suppression over runtime
  hazards.
- Apply his decision-question rewrite.
- Clarify the import-hook getImportsFromRecord "annoying" comment (the dual
  record shape, orthogonal to the arrow/function-keyword spelling).
@kriskowal
kriskowal force-pushed the chore/retire-function-keyword branch from 5ca6a7c to 0393e69 Compare July 1, 2026 17:55
@erights

erights commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

The (24.x macos015) CI failure looks like a flake, so I'm rerunning failed tests.

@kriskowal
kriskowal force-pushed the chore/retire-function-keyword branch from 0393e69 to a97452b Compare July 1, 2026 23:50
kriskowal added 19 commits July 2, 2026 08:12
Add the house-style doc for arrow and concise-method syntax over the
function keyword (rationale, hardened-JS hazards, exception categories)
and link it from AGENTS.md. Reconstructs the doc from endojs/endo-but-for-bots#474.
@kriskowal
kriskowal force-pushed the chore/retire-function-keyword branch from a97452b to 81e6225 Compare July 2, 2026 15:12
kriscendobot pushed a commit to endojs/endo-but-for-bots that referenced this pull request Jul 17, 2026
Apply Mark Miller's CHANGES_REQUESTED review (endojs/endo#3312 review
4575447499, mirrored as #474):

- Rewrite the hoisting-hazards and arrow/concise-method this-binding
  rationale per his suggestion blocks (no TDZ on declarations; harden/freeze
  after a declaration does not prevent earlier mutation; arrow lexical this vs
  caller-this-sensitive concise methods).
- Recommend concise-method generators/async generators by default; correct the
  false claim that function*/async function* is the only spelling.
- Correct the sloppy-mode this-detection note: concise methods are
  caller-this-sensitive, not lexical.
- Replace the function-keyword TypeScript-assertion exception: convert
  assertModuleConfiguration to an arrow (less-hazardous runtime) carrying the
  asserts narrowing via JSDoc @overload, so neither the function keyword nor a
  @ts-expect-error is needed; document preferring suppression over runtime
  hazards.
- Apply his decision-question rewrite.
- Clarify the import-hook getImportsFromRecord "annoying" comment (the dual
  record shape, orthogonal to the arrow/function-keyword spelling).
kriscendobot pushed a commit to endojs/endo-but-for-bots that referenced this pull request Jul 17, 2026
Apply Mark Miller's CHANGES_REQUESTED review (endojs/endo#3312 review
4575447499, mirrored as #474):

- Rewrite the hoisting-hazards and arrow/concise-method this-binding
  rationale per his suggestion blocks (no TDZ on declarations; harden/freeze
  after a declaration does not prevent earlier mutation; arrow lexical this vs
  caller-this-sensitive concise methods).
- Recommend concise-method generators/async generators by default; correct the
  false claim that function*/async function* is the only spelling.
- Correct the sloppy-mode this-detection note: concise methods are
  caller-this-sensitive, not lexical.
- Replace the function-keyword TypeScript-assertion exception: convert
  assertModuleConfiguration to an arrow (less-hazardous runtime) carrying the
  asserts narrowing via JSDoc @overload, so neither the function keyword nor a
  @ts-expect-error is needed; document preferring suppression over runtime
  hazards.
- Apply his decision-question rewrite.
- Clarify the import-hook getImportsFromRecord "annoying" comment (the dual
  record shape, orthogonal to the arrow/function-keyword spelling).
kriscendobot pushed a commit to endojs/endo-but-for-bots that referenced this pull request Jul 17, 2026
Apply Mark Miller's CHANGES_REQUESTED review (endojs/endo#3312 review
4575447499, mirrored as #474):

- Rewrite the hoisting-hazards and arrow/concise-method this-binding
  rationale per his suggestion blocks (no TDZ on declarations; harden/freeze
  after a declaration does not prevent earlier mutation; arrow lexical this vs
  caller-this-sensitive concise methods).
- Recommend concise-method generators/async generators by default; correct the
  false claim that function*/async function* is the only spelling.
- Correct the sloppy-mode this-detection note: concise methods are
  caller-this-sensitive, not lexical.
- Replace the function-keyword TypeScript-assertion exception: convert
  assertModuleConfiguration to an arrow (less-hazardous runtime) carrying the
  asserts narrowing via JSDoc @overload, so neither the function keyword nor a
  @ts-expect-error is needed; document preferring suppression over runtime
  hazards.
- Apply his decision-question rewrite.
- Clarify the import-hook getImportsFromRecord "annoying" comment (the dual
  record shape, orthogonal to the arrow/function-keyword spelling).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants