From c3b01ea64a69b5e1da24893c5d1dc27c710fe17f Mon Sep 17 00:00:00 2001 From: Ron Buckton Date: Fri, 12 Jan 2024 13:37:14 -0500 Subject: [PATCH] Update explainer with syntax, semantics, API, and examples --- README.md | 635 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 626 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f72e5d5..05ec574 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -# ECMAScript Decorators for Function Expressions and Function Declarations +# ECMAScript Decorators for Functions -This proposal seeks to add support for [Decorators](https://github.com/tc39/proposal-decorators) on function expressions and function declarations. +This proposal seeks to add support for [Decorators](https://github.com/tc39/proposal-decorators) on function +expressions, function declarations, and object literal elements. ## Status @@ -16,31 +17,647 @@ _For more information see the [TC39 proposal process](https://tc39.es/process-do # Overview and Motivations -TBA +TBD # Prior Art -TBA +- ECMAScript + - [Decorators](https://github.com/tc39/proposal-decorators) + - [Decorator Metadata](https://github.com/tc39/proposal-decorator-metadata) +- C# 10.0 + - [Attributes on Lambdas](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/lambda-expressions#attributes) +- Python + - [Decorators for Functions and Methods](https://peps.python.org/pep-0318/) # Syntax -TBA +Function Decorators use the same syntax as class and method decorators, except that they may be placed preceding the +leading `function`, `async`, or `export` keywords of a _FunctionExpression_ or _FunctionDeclaration_, before the +_ArrowParameters_ of an _ArrowFunction_, or before the `async` keyword of an _AsyncArrowFunction_: + +```js +// logging/tracing +@logged +function doWork() { ... } + +// utility wrappers +const onpress = @debounce(250) (e) => console.log("button pressed: ", e.pressed); + +// metadata +@ParamTypes(() => [Number, Number]) +@ReturnType(() => Number) +function add(x, y) { return x + y; } + +// React Functional Components +@withStyles({ + root: { border: '1px solid black' }, +}) +@React.forwardRef +function Button(props, forwardedRef) { + ... +} +``` # Semantics -TBA +## Function Expressions and Arrow Functions + +Decorators on function expressions and arrow functions are applied before any reference is returned to the containing +expression. Decorators have the opportunity to augment the function by attaching properties or metadata, wrap or replace +the function with another function, or add "initializers" that are evaluated once decoration application is complete to +perform registration using the final reference for the function. + +## Function Declarations + +Decorators on function declarations should have the same capabilities as those on +[function expressions](#function-expressions-and-arrow-functions), but with an important caveat. In ECMAScript, +function declarations "hoist" — both their name and value are evaluated at the top of the containing _Block_, +which allows them to be invoked prior to their actual declaration: + +```js +const a = foo(); // this is ok because `foo` is "hoisted" above this line. +function foo() { return 1; } +``` + +This is acceptable for function declarations because "hoisting" the value doesn't involve any code execution. However, +decorators require execution before the value is accessible. As a result, we must somehow handle decorator application +before the function itself can be referenced. + +Over the years we have discussed how to address this both in and out of the TC39 plenary. In the end, we are left with +one of five options: + +1. [Introduce a pre-_Evaluation_ step](#option-1-introduce-a-pre-evaluation-step) +1. [Dynamically apply decorators](#option-2-dynamically-apply-decorators) +1. ✨ [Do not hoist decorated functions](#-option-3-do-not-hoist-decorated-functions) +1. [Do not allow decorators on function declarations](#option-4-do-not-allow-decorators-on-function-declarations) +1. [Only allow decorators on marked function declarations](#option-5-only-allow-decorators-on-marked-function-declarations) + +✨ — Approach favored by the proposal champion. + +### Option 1: Introduce a Pre-_Evaluation_ Step + +In this approach, function declarations continue to be hoisted. Before any statements in the containing Script, Module, +FunctionBody, or Block are evaluated, we would first evaluate and apply all decorators. This has several inherent +issues that may make this nonviable, however. + +The decorators themselves must either also be function declarations, or be imported from another file (with no +circular import relationship between the files): +```js +const dec = (target, context) => {}; + +@dec // this is an error because `@dec` ends up evaluated before `const dec` is initialized. +function foo() {} +``` + +Also, arguments to decorator factories cannot reference any variables in scope that are not imported from another file +(again, with no circularities in the import graph). This would make it impossible for decorators to reference constants +declared in the same file, which would be useful when decorating multiple functions with the same base data: +```js +const BASE = "/api/users"; + +@route("PUT", `${BASE}/create`) // this is an error because BASE is not initialized when `@route` is evaluated. +export function createUser(data) { } + +@route("GET", `${BASE}/:id`) +export function getUser(id) { } +``` + +Finally, decorator evaluation order becomes harder to reason over. With class and class element decorators, the +decorator expressions are _evaluated_ in document order. If decorated function declarations hoist, then those decorators' +expressions would no longer evaluate in document order, as they too would be hoisted to the top of the block. + +All of these cases differ from how decorators are applied to `class` declarations, which results in inconsistencies +that are likely to trip up users. As a result, this approach is not recommended. + +### Option 2: Dynamically Apply Decorators + +In this approach, decorators would be evaluated and applied either when execution would reach the declaration of the +function, or when the binding for the function is first accessed: +```js +f; // f is accessed, so f's decorators are applied here + +@dec +function f() {} // execution reaches f, but nothing happens as its decorators are already applied + +@dec +function g() {} // execution reaches g, so g's decorators are applied +``` + +This approach also has major inherent issues. As with [Option 1](#option-1-introduce-a-pre-evaluation-step), decorator +expression evaluation is no longer in document order. Even worse, decorator evaluation order would now potentially be +nondeterministic as different code paths could touch functions in different orders depending on any number of variable +conditions. + +In addition, its fairly common to have function declarations that are never encountered during normal code execution: +```js +function factory() { + return { + getF() { return f; }, + getG() { return g; } + }; + + @dec + function f() {} + + @dec + function g() {} +} +``` +In the above listing, execution will never reach the declarations of `f` or `g` due to the `return`, and user code might +never invoke `getF()` or `getG()` on the result, so it is possible that neither `f` nor `g` will ever have its +decorators applied. This is a significant issue as decorators can be used for _registration_ purposes, such as +registering custom DOM elements, tests, etc., and if there are cases where a decorated function declaration never has +its decorators evaluated it can make an entire program fail in unpredictable ways that are hard to diagnose. As a +result, this approach would encourage users to manually "hoist" their decorated function declarations to avoid these +pitfalls. + +While this approach is quite similar to [Option 3](#option-3-do-not-hoist-decorated-functions), we currently do not +recommend this approach due to the non-determinism and runtime complexity that would be introduced by having to test +whether any variable access is the first access to a decorated function. + +### ✨ Option 3: Do Not Hoist Decorated Functions + +In this approach, function declarations that are decorated do not hoist their values to the top of the block. Instead, +they would behave more like a `let` or `var` declaration initialized to a function expression: +```js +@dec +function f() {} + +// is essentially the same as + +var f = @dec function() {}; +``` + +Much like [Option 2](#option-2-dynamically-apply-decorators), this has the caveat that decorated function declarations +would need to be manually "hoisted" above any code that uses them. However, unlike **Option 2** there is no need for +runtimes to introduce suboptimal first-use checks for variable references. + +It should be noted that this approach introduces a potential refactoring hazard as existing codebases move to adopt +decorators on function declarations, but this can be partially remediated by linters and type systems. + +Despite these caveats, this approach has the benefit that decorator expression evaluation remains in document order and +is completely aligned with class and class element decorators, which would eliminate the guesswork that Options 1 and 2 +would introduce. + +This is the approach currently favored by the proposal champions as it provides the most consistent semantics. + +### Option 4: Do Not Allow Decorators on Function Declarations + +This is by far the simplest option, as it avoids the problem entirely. However, disallowing decorators on function +declarations would be inconsistent with the rest of this proposal and would not grant function declarations the benefits +offered by decorators as indicated in [Overview and Motivations](#overview-and-motivations). As a result, this approach +is not recommended by the proposal champions. + +### Option 5: Only Allow Decorators on Marked Function Declarations + +This approach is similar to [Option 4](#option-4-do-not-allow-decorators-on-function-declarations), in that normal +function declarations cannot be decorated, but also a variant of [Option 3](#option-3-do-not-hoist-decorated-functions) +in which decorated function declarations are not hoisted. Instead of relying on the presense of a decorator to act as a +syntactic opt-in for Option 3's hoisting behavior, this approach would require an _additional_ syntactic opt-in in the +form of a prefix keyword, such as `var`, `let`, or `const`: + +```js +function fn() {} // hoisted as normal, cannot be decorated + +@dec +var function fnv() {} +// equiv. to: +var fnv = @dec function() {}; + + +@dec +let function fnl() {} +// equiv. to: +let fnl = @dec function() {}; + + +@dec +const function fnc() {} +// equiv. to: +const fnc = @dec function() {}; +``` + +While this is an intriguing approach, its possible that some other future proposal might be better served by +`const function`, such as indicating that a function performs no mutations, so we are reticent to recommend this +approach. + +## Object Literal Elements + +Decorators on object literal elements would behave much like their counterparts on classes and class elements. In +addition, this proposal seeks to extend the `accessor` keyword from class fields to be used in conjunction with property +assignments and shorthand property assignments: + +```js +const y = 2; +const obj = { + accessor x: 1, // auto-accessor over a property assignment + accessor y, // auto-accessor over a shorthand property assignment +}; + +const desc = Object.getOwnPropertyDescriptor(obj, "x"); +typeof desc.get; // "function" +typeof desc.set; // "function" +``` + +By supporting `accessor` we would promote reuse of class auto-accessor decorators on object literals. + +## Decorator Expression Evaluation + +Function decorators would be evaluated in document order, as with any other decorators. Function decorators _do not_ +have access to the local scope within the function body, as they are evaluated in the lexical environment that contains +the function instead. + +For example, given the source + +```js +@A +@B +function foo() {} +``` + +the decorator expressions would be _evaluated_ in the following order: `A`, `B`. + +## Decorator Application Order + +Function decorators are applied in reverse order, in keeping with decorator evaluation elsewhere within the language. + +For example, given the source + +```js +@A +@B +function foo() { +} +``` + +decorators would be _applied_ in the following order: + +- `B`, `A` of `function foo()` + +## Metadata + +Much like class decorators, function decorators may define metadata which is then installed on the function itself: + +```js +const meta = (k, v) => (_, context) => { context.metadata[k] = v; }; + +@meta("foo", "bar") +function baz() {} + +console.log(baz[Symbol.metadata]["foo"]); // prints: bar +``` + +Currently, the `context.metadata` property provided to class method decorators is installed on the containing class. As +a result, class methods cannot define metadata that is instead defined on the method itself. This proposal does not seek +to change `context.metadata`, but may choose to seek the addition of a `context.functionMetadata` or +`context.function.metadata` (or similar) property to allow for metadata defined on the method instead. + +Object literal element decorators would behave similarly to class element decorators in that the `context.metadata` +object would be installed on the object literal itself so that all class element decorators can share the same metadata +object, promoting reuse of class method decorators. For consistency with function decorators, we may also seek the +addition of a `context.functionMetadata` (or similar) property for methods requiring function-specific metadata. # Grammar -TBA +TBD. # API -TBA +The API for the decorators introduced in this proposal is consistent with the +[Decorators](https://github.com/tc39/proposal-decorators) proposal. A given decorator will be called by the runtime with +two arguments, `target` and `context`, whose values are dependent on the element being decorated. The return values +of these decorators potentially replaces all or part of the decorated element. + +## Anatomy of a Function Decorator + +A function decorator is expected to accept two parameters: `target` and `context`. Much like a class or class method +decorator, the `target` argument will be the decorated function. + +The `context` for a function decorator would contain useful information about the function: + +```ts +type FunctionDecoratorContext = { + kind: "function"; + name: string | symbol | undefined; + metadata: object; + addInitializer(initializer: () => void): void; +} +``` + +- `kind` — Indicates the kind of element being decorated. +- `name` — The name of the function. The name can potentially be a symbol due to assigned names from property + assignments. +- `metadata` — In keeping with the Decorator Metadata proposal, you would be able to attach metadata to the + function. +- `addInitializer` — This would allow you to attach an extra initalizer that runs after decorator application, + much like you could for a decorator on a class method. + +Returning a function from this decorator will replace the `target` with that function, returning `undefined` will leave +`target` unchanged, and returning anything else is an error. + +## Anatomy of an Object Literal Method/Getter/Setter Decorator + +An object literal method decorator behaves much like a class method decorator, where its `target` is the method being +decorated. + +The `context` for a object literal method decorator would contain useful information about the method: + +```ts +type ObjectLiteralMethodDecoratorContext = { + kind: "object-method"; // or maybe just "method" since they are similar + name: string | symbol; + private: false; + static: false; + metadata: object; + functionMetadata: object; // (if we opt to allow metadata for the function itself) + addInitializer(initializer: () => void): void; +} +``` + +- `kind` — Indicates the kind of element being decorated. +- `name` — The name of the method. +- `private` — Whether the element has a private name. Currently for object literal methods this is always `false`. +- `static` — Whether the element is declared `static`. Currently for object literal methods this is always `false`. + - _NOTE: This is up for debate as it may be that this should be `true` since there is no per-instance evaluation to + consider._ +- `metadata` — In keeping with the Decorator Metadata proposal, you would be able to attach metadata to the + object containing this method. +- `functionMetadata` — If we opt to allow per-function metadata, this would be the unique object installed on the + method. +- `addInitializer` — This would allow you to attach an extra initalizer that runs after decorator application, + much like you could for a decorator on a class method. + +The decorator contexts for getters and setters would behave similarly: + +```ts +type ObjectLiteralGetterDecoratorContext = { + kind: "object-getter"; // or just "getter"? + ... // other properties from ObjectLiteralMethodDecoratorContext +} + +type ObjectLiteralSetterDecoratorContext = { + kind: "object-setter"; // or just "setter"? + ... // other properties from ObjectLiteralMethodDecoratorContext +} +``` + +Returning a function from this decorator will replace the `target` with that function, returning `undefined` will leave +`target` unchanged, and returning anything else is an error. + +## Anatomy of an Object Literal Property Assignment Decorator + +Property assigment (and shorthand property assignment) decorators behave much like class field decorators, where the +`target` is always `undefined`. + +The `context` for an object literal property assignment decorator would contain useful information about the property: + +```ts +type ObjectLiteralPropertyDecoratorContext = { + kind: "object-property"; // or just "property" + name: string | symbol; + private: false; + static: false; + metadata: object; + addInitializer(initializer: () => void): void; +} +``` + +- `kind` — Indicates the kind of element being decorated. +- `name` — The name of the property. +- `private` — Whether the element has a private name. Currently for object literal properties this is always + `false`. +- `static` — Whether the element is declared `static`. Currently for object literal properties this is always + `false`. + - _NOTE: This is up for debate as it may be that this should be `true` since there is no per-instance evaluation to + consider._ +- `metadata` — In keeping with the Decorator Metadata proposal, you would be able to attach metadata to the + object containing this property. +- `addInitializer` — This would allow you to attach an extra initalizer that runs after decorator application, + much like you could for a decorator on a class field. + +Returning a function from this decorator would chain a new initializer mutator, much like a class field decorator: + +```js +const addOne = (_target, _context) => x => x + 1; + +const obj = { + @addOne x: 2, +}; + +console.log(obj.x); // 3 +``` + +Returning `undefined` will leave the initializer mutator chain unchanged, and returning anything else is an error. + +## Anatomy of an Object Literal Auto-Accessor Decorator + +Auto-accessor property assigment (and shorthand property assignment) decorators behave much like class auto-accessor +decorators, where the `target` is a `{ get, set }` object pointing to the current getter and setter for the +auto-accessor. + +The `context` for an object literal auto-accessor decorator would contain useful information about the property: + +```ts +type ObjectLiteralAutoAccessorDecoratorContext = { + kind: "object-accessor"; // or maybe just "accessor" since they are similar + name: string | symbol; + private: false; + static: false; + metadata: object; + accessorMetadata: { get: object, set: object }; // (if we opt to allow metadata for the functions themselves) + addInitializer(initializer: () => void): void; +} +``` + +- `kind` — Indicates the kind of element being decorated. +- `name` — The name of the element. +- `private` — Whether the element has a private name. Currently for object literal elements this is always + `false`. +- `static` — Whether the element is declared `static`. Currently for object literal elements this is always + `false`. + - _NOTE: This is up for debate as it may be that this should be `true` since there is no per-instance evaluation to + consider._ +- `metadata` — In keeping with the Decorator Metadata proposal, you would be able to attach metadata to the + object containing this element. +- `addInitializer` — This would allow you to attach an extra initalizer that runs after decorator application, + much like you could for a decorator on a class element. + +Returning a `{ get, set, init }` object from this decorator would allow for replacement of the target getter or setter, +or chain a new initializer mutator, much like a class auto-accessor decorator: + +```js +const addOne = (_target, _context) => ({ init: x => x + 1 }); + +const obj = { + @addOne accessor x: 2, +}; + +console.log(obj.x); // 3 +``` + +Returning `undefined` will leave the target getter, setter, and initializer mutator chain unchanged. Returning +`undefined` for any of the `get`, `set`, or `init` properties will leave the getter, setter, or initializer mutator +chain unchanged, respectively. Returning anything else is an error. # Examples -TBA +## Function Wrapper Utilities + +### Debouncing Input + +```js +function debounce(timeout) { + return function (target, context) { + switch (context.kind) { + case "method": + case "object-method": + case "function": + break; + default: + throw new Error(`Not supported for kind: ${context.kind}`); + } + + let timer; + let deferred; + function run(thisArg, args) { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + + const { resolve, reject } = deferred; + deferred = undefined; + try { + resolve(target.apply(thisArg, args)); + } + catch (e) { + reject(e); + } + } + + return function (...args) { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + deferred ??= Promise.withResolvers(); + timer = setTimeout(() => run(this, args), timeout); + return deferred.promise; + }; + } +} + +obj.on("change", @debounce(100) e => { ... }); +``` + +### Retry An Operation + +```js +function retry({ maxAttempts, shouldRetry }) { + return function (target, context) { + switch (context.kind) { + case "method": + case "object-method": + case "function": + break; + default: + throw new Error(`Not supported for kind: ${context.kind}`); + } + + return async function (...args) { + for (let i = maxAttempts; i > 1; i--) { + try { + return await target.apply(this, args); + } + catch (e) { + if (!shouldRetry || shouldRetry(e)) continue; + throw e; + } + } + return await target.apply(this, args); + } + } +} + +@retry({ maxAttempts: 3, shouldRetry: e => e extends IOError }) +export async function downloadFile(url) { ... } +``` + +## Authorization in a Multi-User Web Application + +> NOTE: This example leverages the proposed [AsyncContext](https://github.com/tc39/proposal-async-context). + +```js +const authVar = new AsyncContext.Variable(); + +function auth(role) { + return function (target, context) { + switch (context.kind) { + case "method": + case "object-method": + case "function": + break; + default: + throw new Error(`Not supported for kind: ${context.kind}`); + } + + return function (...args) { + const user = authVar.get(); + if (!user) throw new Error("Not authenticated"); + if (!user.isInRole(role)) throw new Error("Not authorized"); + return target.apply(this, args); + }; + }; +} + +export function runAs(user, cb) { + return authVar.run(user, cb); +} + +@auth("Administrator") +export async function createUser(newUser) { ... } +``` + +In this example, a multi-user web application would establish the current user context via a call to `runAs`. Within the +callback, if `createUser` is invoked then the user associated with the current `AsyncContext.Variable` is retrieved and +access is checked before the function body itself can be invoked. + +## Serverless Apps on AWS Lambda + +These examples derived from AWS's [Chalice](https://github.com/aws/chalice), a Python library for Amazon Web Services. +These examples involve concepts such as attaching metadata and registration. + +### Rest APIs + +```js +import { Chalice } from "chalice"; +const app = new Chalice({ appName: "helloworld" }); + +@app.route("/") +function index() { return { "hello": "world" }; } +``` + +### Scheduled Tasks + +```js +import { Chalice, Rate } from "chalice"; +const app = new Chalice({ appName: "helloworld" }); + +@app.schedule(new Rate(5, { unit: Rate.MINUTES })) +function periodicTask(event) { ... } +``` + +#### Connect a lambda function to an S3 event + +```js +import { Chalice, Rate } from "chalice"; +const app = new Chalice({ appName: "helloworld" }); + +@app.on_s3_event({ bucket: "mybucket" }) +function handler(event) { + console.log(`Object uploaded for bucket: ${event.bucket}, key: ${event.key}`); +} +``` # Related Proposals