Skip to content

tc39/proposal-class-method-parameter-decorators

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ECMAScript Decorators for Class Method and Constructor Parameters

This proposal adds support for decorators on the parameters of class constructors and class methods.

Status

Stage: 1
Champion: Ron Buckton (@rbuckton)
Last Presented: March, 2023

For more information see the TC39 proposal process.

Authors

  • Ron Buckton (@rbuckton)

Overview and Motivations

Decorators are a metaprogramming capability for ECMAScript which allow you to annotate a declaration with a function reference that will be invoked with arguments pertaining to the defining characteristics of that declaration, as well as to potentially replace or augment the declaration. The current Stage 3 Decorators proposal allows for the use of Decorators on class declarations and expressions, as well as their methods, getters, setters, and fields.

Parameter decorators extend this metaprogramming capability to target the parameters of class constructors and class methods, and are intended to support a number of use cases including:

  • Constructor parameter-based Dependency Injection (DI).
  • Object-relational mapping (ORM) entity construction that couples with private fields.
  • Method parameter marshaling for Foreign Function Interfaces (FFI).
  • Metadata for Parameters.
  • Routing: bind HTTP request headers/querystring parameters/post body fields/etc. to parameters.
  • Argument validation: disallow null/undefined, range validation, regexp string validation, etc.

Many of these scenarios are drawn from existing uses of legacy decorators in TypeScript, as well as similar capabilities in languages like Java and C#. For example, VS Code makes heavy use of constructor parameter decorators for dependency injection.

Parameter decorators allow you to easily annotate a method or constructor parameter so as to ascribe specific metadata or to alter behavior:

class UserManager {
  createUser(@NotEmpty username, @NotEmpty password, @ValidateEmail emailAddress, @MinValue(0) age) { }
}

While its feasible to do the same with regular method decorators, you are only really able to associate such a decoration via a parameter's ordinal position. It becomes much harder to maintain them over time as parameters are added, removed, or reordered, as well as far more difficult to read and review when you must relate a decorator and a parameter visually based solely on ordinal position:

  class UserManager {
    @param(3, ValidateEmail)
    @param(2, MinValue(0))
    @param(1, NotEmpty)
    @param(0, NotEmpty)
--  createUser(username, password, age, emailAddress) { ... }
++  createUser(username, password, emailAddress, age) { ... }
++  // oops, forgot to fix @param decorator order...
  }

Method decorators also don't provide useful context for a parameter, such as its name, that could otherwise be leveraged by something like a @FromForm parameter in an HTTP router:

// without parameter decorators:
class BookApi {
  @Route("/book/:isbn/review", { method: "post", form: true })
  @param(0, FromUri({ name: "isbn" })) // need to repeat declaration name here and in parameter list...
  @param(1, FromForm({ name: "subject" }))
  @param(2, FromForm({ name: "description" }))
  @param(3, FromForm({ name: "score" }))
  postReview(isbn, subject, description, score) { ... }
}

// with parameter decorators:
class BookApi {
  // no need for repetition if the names match...
  @Route("/book/:isbn/review", { method: "post", form: true })
  postReview(@FromUri isbn, @FromForm subject, @FromForm description, @FromForm score) { ... }
}

Parameter decorators might also provide a useful avenue for data validation and transformation by providing a mechanism to observe and potentially replace an incoming argument, similar to how a field decorator allows you to observe and potentially replace an initializer:

// argument validation (observe argument without mutating it):

function NotEmpty(target, context) {
  if (context.kind !== "parameter") throw new TypeError();
  return function (arg) {
    if (typeof arg !== "string" || arg.length !== 0) {
      throw new TypeEror(`Argument '${context.name}' expects a non-empty string`);
    }
    return arg
  }
}

// FFI marshaling (marshal input argument from foreign type to native type):

/** Convert FFI pointer for length-prefixed BSTR to a `string` */
function BStr(target, context) {
  return function (arg) {
    if (arg instanceof ffi.Pointer) {
      return arg.asBStr();
    }
    return arg;
  }
}

/** Convert FFI pointer for a 2-byte null-terminated unicode character string to a `string` */
function LPWStr(target, context) {
  return function (arg) {
    if (arg instanceof ffi.Pointer) {
      return arg.asLPWStr();
    }
    return arg;
  }
}

Prior Art

Syntax

Parameter decorators use the same syntax as class and method decorators, except they may be placed preceding a parameter in a class constructor or class method declaration:

// constructor parameter decorators
class CustomizationService {
  constructor(
    @inject("StorageService") storageService,
    @inject("UserProfileService") userProfileService
  ) {
    ...
  }
}

// method parameter decorators:
class BookApi {
  @Route("/book/:isbn", { method: "get" })
  getBook(@FromUri isbn) { ... }

  @Route("/book/:isbn/review", { method: "post", form: true })
  postReview(@FromUri isbn, @FromForm subject, @FromForm description, @FromForm score) { ... }
}

// setter parameters:
class User {
  ...
  get username() { return this.#username; }
  set username(@NotEmpty value) { this.#username = value; }
}

Parameter decorators on object literal methods and setters, or on function declarations and expressions, are out of scope for this proposal. However, we intend for the design of this proposal to allow for future expansion into that space once function decorators have been adopted within the language.

Grammar

The following is a rough outline of the proposed grammar and is not intended to be interpreted as the final syntax. Should this proposal be adopted, it is possible the syntax could change based on feedback.

  FunctionRestParameter[Yield, Await] :
--    BindingRestElement[?Yield, ?Await]
++    DecoratorList[?Yield, ?Await]? BindingRestElement[?Yield, ?Await]

  FormalParameter[Yield, Await] :
--    BindingElement[?Yield, ?Await]
++    DecoratorList[?Yield, ?Await]? BindingElement[?Yield, ?Await]

Semantics

Early Errors

Along with the above proposed grammar, it would be an early error if DecoratorList were in the FormalParameters of a FunctionDeclaration, FunctionExpression, GeneratorDeclaration, GeneratorExpression, AsyncFunctionDeclaration, AsyncFunctionExpression, AsyncGeneratorDeclaration, AsyncGeneratorExpression, ArrowFunction, or AsyncArrowFunction. It would also be an early error if DecoratorList were in the FormalParameters of a MethodDefinition if that MethodDefinition is immediately contained within an ObjectLiteralExpression.

Decorator Expression Evaluation

Parameter decorators would be evaluated in document order, as with any other decorators. Parameter decorators do not have access to the local scope within the method body, as they are evaluated statically, at the same time as any decorators that might be on their containing method or class.

For example, given the source

@A
@B
class Cls {
  @C
  @D
  method(@E @F param1, @G @H param2) { }
}

the decorator expressions would be evaluated in the following order: A, B, C, D, E, F, G, H

Decorator Application Order

Parameter decorators will be applied prior to application of any decorators on their containing method. The parameter decorators for a given parameter are applied independently of those on a subsequent parameter. Within the decorators of a single parameter, those decorators are applied in reverse order, in keeping with decorator evaluation elsewhere within the language.

For example, given the source

@A
@B
class Cls {
  @C
  @D
  method(@E @F param1, @G @H param2) { }
}

decorators would be applied in the following order:

  • F, E of param1
  • H, G of param2
  • D, C of method
  • B, A of class Cls

Anatomy of a Parameter Decorator

A parameter decorator is generally expected to have two parameters: target and context. Much like a field decorator, however, the target argument will always be undefined as a parameter is not itself a reified object in JavaScript.

The context for a parameter decorator would contain useful information about the parameter:

type ParameterDecoratorContext = {
  kind: "parameter";
  name: string | undefined;
  index: number;
  rest: boolean;
  function: {
    kind: "class" | "method" | "setter";
    name: string | symbol | undefined;
    static?: boolean;
    private?: boolean;
  };
  metadata: object;
  addInitializer(initializer: () => void): void;
}
  • kind — Indicates the kind of element being decorated.
  • name — A string if the parameter is named, or undefined if the parameter is a binding pattern.
  • index — The ordinal position of the parameter in the parameter list, which is necessary for constructor parameter injection for scenarios like dependency injection.
  • rest — Indicates whether the parameter is a ... rest element.
  • function — Contains limited information about the function to which the parameter belongs, which is necessary to distinguish between members when assigning to the metadata property, and in the future to distinguish between class method parameters and function parameters, as that may also impact how metadata is assigned.
  • metadata — In keeping with the Decorator Metadata proposal, you would be able to attach metadata to a class.
  • addInitializer — This would allow you to attach an extra static or instance initalizer, much like you could for a decorator on the containing method.

A parameter decorator may either return undefined, or a function value. If a function is returned, it will be later invoked when that parameter is bound during its containing function's invocation. This behaves much like a function returned from a field decorator:

class C {
  method(@A param1) {
    ...
  }
}

is roughly equivalent to

var _param1_init
class C {
  static {
    _param1_init = A(undefined, { kind: "parameter", name: "param1", index: 0, /*...*/ });
  }
  method(param1) {
    if (_param1_init !== undefined) param1 = _param1_init.call(this, param1);
    ...
  }
}

Examples

ECMAScript

Dependency Injection (DI)

Dependency Injection systems often use constructor parameter injection to satisfy dependencies when an instance of a component is requested. This allows such a component to perform additional initialization logic and set private fields:

customizationService.js

import { inject } from "di-framework"

class CustomizationService {
  #storageService;
  #authorizationService;
  constructor(
    @inject("StorageService") storageService,
    @inject("AuthorizationService") authorizationService
  ) {
    storageService.ensurePerUserStorage();
    this.#storageService = storageService;
    this.#authorizationService = authorizationService;
  }

  setTheme(userId, theme) {
    if (!this.#authorizationService.currentUserHasPermission(userId, ["CHANGE_PROFILE"])) {
      throw new Error()
    }
    this.#storageServce.writeProperty(`${userId}/profile/theme`, theme);
  }
}

Composition allows you to easily stitch together a complex application with many disparate parts, as well as customize per-environment dependencies:

main.js

import { Container } from "di-framework"
import { FileSystemStorageService } from "./fileSystemStorageService.js"
import { CloudStorageService } from "./cloudStorageService.js"
import { AuthorizationService } from "./authorizationService.js"
import { CustomizationService } from "./customizationService.js"
import { HttpService } from "./httpService.js"
import { Application } from "./app.js"

export function main(useCloudStorage) {
  const container = new Container()
  container.set("StorageService", useCloudStorage ? CloudStorageService : FileSystemStorageService)
  container.set("AuthorizationService", AuthorizationService)
  container.set("CustomizationService", CustomizationService)

  ...

  const customizationService = container.get("CustomizationService")
  customizationService.setTheme(userId, theme)
}

One of the advantages of constructor parameter injection is that it makes it fairly easy to test a component or service in isolation through the use of mock or fake implementations of dependencies:

customizationService.tests.js

import { CustomizationService } from "./customizationService.js"

describe("CustomizationService tests", () => {
  it("throws when access invalid", () => {
    const fakeStorageService = { ensurePerUserStorage() {} };
    const fakeAuthorizationService = { currentUserHasPermission: (userId, permissions) => false };
    const customizationService = new CustomizationService(fakeStorageService, fakeAuthorizationService);
    expect(() => customizationService.setTheme(1234, "dark")).toThrow();
  })
});

Object-Relational Mapping (ORM)

Many ORM systems leverage user-defined classes to model an entity:

@Entity()
export class User {
  @Field({ type: "string" })
  id;
  @Field({ type: "string" })
  email;
  @Field({ type: "byte(16)" })
  passwordHash;
  @Field({ type: "string" })
  fullName;

  constructor(id, passwordHash, email, fullName) {
    this.id = id;
    this.passwordHash = passwordHas;
    this.email = email;
    this.fullName = fullName;
  }
}

However, they often do so by ignoring the constructor and using Object.create(). This makes rehydrating an entity difficult when that entity might have private class elements:

@Entity()
export class User {
  @Field({ type: "string" })
  id;
  @Field({ type: "string" })
  email;
  @Field({ type: "byte(16)" })
  #passwordHash; // cannot use Object.create
  @Field({ type: "string" })
  fullName;

  constructor(id, password, email, fullName) {
    this.id = id;
    this.passwordHash = password;
    this.email = email;
    this.fullName = fullName;
  }
}

If the entity has a private field, the ORM can no longer rehydrate it without calling the constructor, and needs a mechanism to associate a database record field with the associated parameter. Today, ORMs often handle this by just passing in an object containing key/value mappings for the record, but that can often result in the need to overload the constructor to handle both ORM construction and a constructor designed for ease of use by users.

To make this easier, constructor parameters could be used to signal to the ORM system which fields should be supplied for which parameters, and in what order:

@Entity({ constructable: true })
export class User {
  @Field({ type: "string" })
  id;
  @Field({ type: "string" })
  email;
  @Field({ type: "byte(16)" })
  #passwordHash; // cannot use Object.create
  @Field({ type: "string" })
  fullName;
  @Field({ type: "timestamp" })
  #createdOn;

  constructor(
    @Field() id,
    @Field({ name: "passwordHash" }) password,
    @Field() email,
    @Field() fullName,
    @Field() createdOn = new Date()
  ) {
    this.id = id;
    this.passwordHash = password;
    this.email = email;
    this.fullName = fullName;
    this.#createdOn = createdOn;
  }
}

Foreign Function Interfaces

Packages like ffi-napi can be used to call into native code from NodeJS. Passing ECMAScript callbacks to native code requires, marshalling parameters and return values to and from native formats:

// Interface into the native lib
let libname = ffi.Library('./libname', {
  'setCallback': ['void', ['pointer']]
});

// Callback from the native lib back into js
let callback = ffi.Callback('void', ['int', 'string'],
  function(id, name) {
    console.log("id: ", id);
    console.log("name: ", name);
  });

libname.setCallback(callback);

With parameter decorators, we could easily annotate marshalling behavior on the parameter itself:

class MyClass {

  @MarshalReturnAs("void")
  static callback(
    @MarshalAs("int") id,
    @MarshalAs("string") name
  ) {
    console.log("id: ", id);
    console.log("name: ", name);
  }

  static {
    let libname = ffi.Library('./libname', {
      'setCallback': ['void', ['pointer']]
    });
    libname.setCallback(this.callback);
  }
}

HTTP Routing

Class methods are a great parallel to web API routes in a REST web service. Method parameter decorators could facilitate how route parameters, querystring values, POST bodies, and form fields are mapped to parameters:

export class BookApi {
  // examples:
  //  GET /books
  //  GET /books?p=2
  //  GET /books?p=3&ps=25
  @Get("/books")
  getBooks(@FromQuery("p") page = 1, @FromQuery("ps") pageSize = 10) {
    ...
  }

  // examples:
  //  GET /book/123-4567890123
  @Get("/book/:isbn")
  getBook(@FromRoute isbn) {
    ...
  }

  @Post("/book/:isbn/review", { form: true })
  postReviewForm(@FromRoute isbn, @FromSession user, @FromForm subject, @FromForm description, @FromForm score) { }

  @Post("/book/:isbn/review", { json: true })
  postReviewJson(@FromRoute isbn, @FromSession user, @FromBody { subject, description, score }) { }
}

Parameter Validation

Parameter validators allow you to validate constructor and method inputs concisely:

export class UserManager {
  createUser(
    @NotEmpty() username,
    @NotEmpty() password,
    @EmailValidator() email,
    @NotEmpty() fullName,
    @MinValue(13) age
  ) {
    ...
  }
}

const mgr = new UserManager();
mgr.createUser("", "", "invalid#email", "", 0) // throws

In-the-wild Examples From TypeScript

There are thousands of instances of constructor and method parameter decorators on GitHub. The following are a handful of examples from popular mainstream libraries.

NestJS

NestJS uses constructor parameter decorators for dependency injection, routing, and message parameter binding:

packages/common/pipes/parse-int.pipe.ts

import { Injectable } from '../decorators/core/injectable.decorator';
import { Optional } from '../decorators/core/optional.decorator';

...

@Injectable()
export class ParseIntPipe implements PipeTransform<string> {
  protected exceptionFactory: (error: string) => any;

  constructor(@Optional() options?: ParseIntPipeOptions) {
    ...
  }
}

integration/websockets/src/app.gateway.ts

import {
  MessageBody,
  SubscribeMessage,
  WebSocketGateway,
} from '@nestjs/websockets';

@WebSocketGateway(8080)
export class ApplicationGateway {
  @SubscribeMessage('push')
  onPush(@MessageBody() data) {
    return {
      event: 'pop',
      data,
    };
  }
}

integration/repl/src/users/users.controller.ts

...
@Controller('users')
export class UsersController {
  ...
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(+id);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update(+id, updateUserDto);
  }
  ...
}

plus 181 others...

Angular

Angular uses constructor parameter decorators for dependency injection, as well as HTML element attribute binding:

main/packages/platform-browser/src/browser.ts (dependency injection)

...
@NgModule({
  providers: [
    ...BROWSER_MODULE_PROVIDERS,  //
    ...TESTABILITY_PROVIDERS
  ],
  exports: [CommonModule, ApplicationModule],
})
export class BrowserModule {
  constructor(@Optional() @SkipSelf() @Inject(BROWSER_MODULE_PROVIDERS_MARKER)
              providersAlreadyPresent: boolean|null) {
    ...
  }
  ...
}

main/packages/examples/core/ts/metadata/metadata.ts (attribute binding)

...
@Directive({selector: 'input'})
class InputAttrDirective {
  constructor(@Attribute('type') type: string) {
    // type would be 'text' in this example
  }
}
...

plus 203 others...

PrimeNG for Angular

PrimeNG uses constructor parameter decorators as part of Angular's dependency injection system:

src/app/components/tree/tree.ts

...
export class UITreeNode implements OnInit {
  ...
  constructor(@Inject(forwardRef(() => Tree)) tree) {
    ...
  }
  ...
}

src/app/components/messages/messages.ts

...
export class Messages implements AfterContentInit, OnDestroy {
  ...
  constructor(@Optional() public messageService: MessageService, public el: ElementRef, public cd: ChangeDetectorRef) {}
  ...
}
...

plus 8 others...

Related Proposals

TODO

The following is a high-level list of tasks to progress through each stage of the TC39 proposal process:

Stage 1 Entrance Criteria

  • Identified a "champion" who will advance the addition.
  • Prose outlining the problem or need and the general shape of a solution.
  • Illustrative examples of usage.
  • High-level API.

Stage 2 Entrance Criteria

Stage 3 Entrance Criteria

Stage 4 Entrance Criteria

  • Test262 acceptance tests have been written for mainline usage scenarios and merged.
  • Two compatible implementations which pass the acceptance tests: [1], [2].
  • A pull request has been sent to tc39/ecma262 with the integrated spec text.
  • The ECMAScript editor has signed off on the pull request.

About

Decorators for ECMAScript class method and constructor parameters

Resources

License

Code of conduct

Security policy

Stars

Watchers

Forks

Languages