proposal-class-method-parameter-decorators
proposal-class-method-parameter-decorators copied to clipboard
Examples don't clearly demonstrate need
There's really only two use cases listed by the different examples in the Motivations section:
- Parameter Validation
- Dependency Injection
The problem I have is that these seem to be seeking a particular API rather than fill a hole in the capabilities of the language.
For example, in parameter validation, this is the example given:
class UserManager {
createUser(
@NotEmpty username,
@NotEmpty password,
@ValidateEmail emailAddress,
@MinValue(0) age
) {
// ...
}
}
It's compared against order dependent method parameters, but this is the obvious alternative to me:
import { assert, ...etc } from "library"
class UserManager {
createUser(username, password, emailAddress, age) {
assert(NotEmpty(username))
assert(NotEmpty(password))
assert(ValidateEmail(emailAddress))
assert(MinValue(age, 0))
// ...
}
}
The other example is dependency injection:
class CustomizationService {
constructor(
@inject("StorageService") storageService,
@inject("UserProfileService") userProfileService
) {
...
}
}
runtime.register(CustomizationService)
It's clear that this is a constructor that must be called by some other runtime in a very specific way.
class CustomizationService {
constructor() {
let storageService = runtime.get("StorageService")
let userProfileService = runtime.get("UserProfileService")
// ...
}
}
runtime.register(CustomizationService)
Note: Yes, this removes metadata that would allow you to construct services ahead of time or detect circular dependencies. However, it's still possible to implement these things and not meaningfully different code for this imaginary runtime to include.
Or alternatively:
class CustomizationService {
@inject("StorageService") storageService;
@inject("UserProfileService") userProfileService;
constructor() {
// ...
}
}
runtime.register(CustomizationService)
Either way, the dependency injection example is omitting some important details that the constructor isn't actually callable outside of some larger runtime.
In both examples, this syntax feels minimally better than existing alternatives, and considering parameter lists are already complex (and growing in complexity!) it feels like it should be very clearly demonstrating a need.
There's really only two use cases listed by the different examples in the Motivations section:
- Parameter Validation
- Dependency Injection
I would ask that you reread the Motivations section, as I don't believe that is an accurate assessment. There are actually no examples of constructor-based DI in the Motivations section, though there is one in the Syntax section. There are, however, examples of Parameter Validation, Web API Routing, and FFI Marshaling.
The problem I have is that these seem to be seeking a particular API rather than fill a hole in the capabilities of the language.
It's true that I am pushing for a particular API design, as we have evidence from TypeScript, C#, Java, and Rust that such a design is extremely useful.
For example, in parameter validation, this is the example given:
class UserManager { createUser( @NotEmpty username, @NotEmpty password, @ValidateEmail emailAddress, @MinValue(0) age ) { // ... } }It's compared against order dependent method parameters, but this is the obvious alternative to me:
import { assert, ...etc } from "library" class UserManager { createUser(username, password, emailAddress, age) { assert(NotEmpty(username)) assert(NotEmpty(password)) assert(ValidateEmail(emailAddress)) assert(MinValue(age, 0)) // ... } }
Yes, such assertions are currently already feasible as you describe, but are not observable from outside of the method. One of the advantages that parameter decorators have over regular assertions is that they can also advertise validation to consumers through capabilities such as metadata. This is especially useful when coupled with REST-style web APIs as it allows for generation of service descriptions/service contracts, as well as the potential to be used to drive dynamic form validation on the web.
The other example is dependency injection:
class CustomizationService { constructor( @inject("StorageService") storageService, @inject("UserProfileService") userProfileService ) { ... } } runtime.register(CustomizationService)It's clear that this is a constructor that must be called by some other runtime in a very specific way.
class CustomizationService { constructor() { let storageService = runtime.get("StorageService") let userProfileService = runtime.get("UserProfileService") // ... } } runtime.register(CustomizationService)Note: Yes, this removes metadata that would allow you to construct services ahead of time or detect circular dependencies. However, it's still possible to implement these things and not meaningfully different code for this imaginary runtime to include.
Or alternatively:
class CustomizationService { @inject("StorageService") storageService; @inject("UserProfileService") userProfileService; constructor() { // ... } } runtime.register(CustomizationService)
Ahead-of-time, and even on-demand service instantiation is extremely valuable. As is the ability to detect whether a component may have unsatisfied dependencies before allocation. Field decorators can be used for this as well, but cannot easily handle component initialization, such as when a constructor must perform some initialization steps that depend on a dependency. For example, a component in DI system used by an editor might depend on a service that provides the ability to register a custom URL moniker for proper URL handling in Electron, or in some other runtime.
Either way, the dependency injection example is omitting some important details that the constructor isn't actually callable outside of some larger runtime.
One of the benefits of constructor parameter injection is that you can define a constructor that does not require the constructor be called in a specific way by some other runtime. Rather, the inverse is true: the parameter decorators inform a runtime how to call your constructor correctly. This is important not just for initialization logic tied to injected dependencies, but also for cases like ORM entity hydration. It's also extremely useful for the example of testing a DI component at the end of the dependency injection example, which is intended to show that such a component isn't intended to only be used inside of a larger runtime. The goal is actually to isolate that complexity to make testing far easier, and is a pattern broadly employed in languages like C# by DI frameworks like MEF (Managed Extensibility Framework) and ninject.
In both examples, this syntax feels minimally better than existing alternatives, and considering parameter lists are already complex (and growing in complexity!) it feels like it should be very clearly demonstrating a need.
I believe the "in-the-wild" examples from TypeScript very clearly indicate the power and flexibility such a metaprogramming syntax can offer. These are not niche or isolated use cases, but are employed by major frameworks like Angular, NestJS, and InversifyJS, to name a few.
This also isn't a niche feature. C#, Java, and Rust all have a parameter annotation mechanism, even if it is only limited to metadata.
I'd also like to note that Angular 1.0, which far predates decorators in TypeScript, depended on constructor parameter injection as well. Unfortunately, it did so via specially crafted parameter names and heavy use of Function.prototype.toString(), which wasn't a very reliable mechanism at the time since Function.prototype.toString() was implementation dependent (and still is today in runtimes where source isn't recoverable).
I work on a library used to implement Web IDL constructs in ES. Parameter decoration appears capable of addressing pain points which arise in this space. In addition to associated type conversions and arity checks which must occur in a specific order where observable, the parameter lists themselves need to be introspectable in order to realize Web IDL’s overload resolution algorithm (including awareness of parameter variad...icity(?) and whether they have a default initializer). Parameter names also need to be introspectable to produce error messages which align with native implementations.
The problem I have is that these seem to be seeking a particular API rather than fill a hole in the capabilities of the language.
The hole from my POV is a lack of introspection capabilities, but I can see how that’s not the same kind of “capability” hole as, say, the one WeakRef/FinalizationRegistry filled; it could be viewed as “all API” given these things weren’t introspectable previously and therefore couldn’t be said to “exist” at runtime at all. Or something like that?
For what it’s worth, though, it seems like a good sign to me that this proposal “happens to” (afaict) address all of the needs of a (non-TypeScript) library @rbuckton has never seen, i.e. this seems to suggest that, all-API or not, it’s providing the right generic hooks for real world use cases.
This also isn't a niche feature. C#, Java, and Rust all have a parameter annotation mechanism, even if it is only limited to metadata.
This statement is doing a lot of work. I think it's very wrong to compare this proposal to features in other languages that rely on the existence of a compiler to be interesting. We could copy and paste the exact syntax from Rust and they are still two entirely different features.
I think it's very wrong to compare this proposal to features in other languages that rely on the existence of a compiler to be interesting.
That's far from true. .NET Attributes are accessible at runtime via reflection, which is how they are actually used by DI systems, web api routing, test frameworks, etc. The same benefits that C#'s Attributes and Java's Annotations provide could be directly modeled via a combination of parameter decorators + metadata, with cls[Symbol.metadata] as the reflection mechanism. In fact, that was the original intent behind parameter decorators in TypeScript's --experimentalDecorators. I'd be thrilled to advance a version of parameter decorators that are only able to attach metadata, as that is the current extent of TypeScript's parameter decorator support. However, what we've found is that a metadata-only decorator is somewhat limiting, and scenarios like parameter validation are somewhat unwieldy due to the need to also leverage a method decorator to affect behavior. This proposal hopes to explore this design space further.