Proposal: Uniform Function Call Syntax (...sort of) #12630
Labels
Out of Scope
This idea sits outside of the TypeScript language design constraints
Suggestion
An idea for TypeScript
Uniform Function Call Syntax is a feature of the D programming language: a means of calling in-scope functions in a manner like methods, where the object being accessed with the dot-operator is passed to the function as its first parameter.
Calling the proposal below "Unified Function Call Syntax" is perhaps a bit of a misnomer. It is really a fusion of UFCS and C#'s extension methods, adapted to TypeScript's structural type-system as well as trying not to break any expectations carried in from the JavaScript community. The result is not exactly "uniform" in various ways.
If I were to give it a better name, I might call it something like "Procedural Methods" or "Case-Methods", but these are coined-terms; there is probably a name that already exists in computer science for this, I just don't know it. I will still be using UFCS in the proposal, but feel free to drop a better name if you got one.
Why Bring UFCS to TypeScript?
It supports programming patterns currently in the wild.
It is far from an uncommon practice in JavaScript to create modules and libraries that expose functions that are not tied to the objects they work on as direct methods.
Whole libraries, such as the popular Underscore.js, are made to supplement common JavaScript objects and structures, and so need a way to work on these objects in a non-destructive way. Because modifying native prototypes is considered bad form, these libraries prefer exposing bare functions that take the object they work on as their first argument. UFCS would greatly ease their use in TypeScript.
It is also not uncommon for larger frameworks to create modules with the same sort of pattern. These modules work as companions to the framework's objects, exposing related functionality that didn't necessarily fit as methods on the objects themselves. Ember's RSVP module and React's many add-ons are examples of this.
It compliments object-oriented programming.
There is often a compulsion to jam as much behavior into a class as methods as possible, just so those behaviors can be accessed with the nice and convenient dot-operator. This ultimately trends toward classes that are ultimately less adaptable and more tightly coupled to other parts of the software, creating something that is actually harder to reuse. All that behavioral baggage it carries makes it more difficult to extend the class with greater specialization.
UFCS makes it more palatable to separate as much behavior from the class as possible, only encapsulating necessary behaviors, those that manipulate private data, within classes as real methods. There is no need to sacrifice adaptability for convenience.
It improves TypeScript's type-system.
Structure and behavior are two sides of the same coin. TypeScript features compile-time abstractions for structure through the
type
andinterface
keywords. However, there is no equivalent compile-time abstraction for behavior. In essence, we have a coin with only one side.This is unfortunate. Large JavaScript applications that rely on multiple libraries have a good chance of having redundant objects flowing through them: similar structure and purpose, but totally different prototypes. A programmer needs to have a way of juggling these different types in a clean manner. This usually is solved, structurally, by creating a common interface for them, but interfaces cannot have behavior attached to them. This leads toward more tedious solutions to get the behavioral side of the coin, such as wrapper objects and DSLs.
UFCS can provide that behavioral abstraction, allowing complete types, with both structure and behavior, to be described as compile-time abstractions with no performance impact in run-time and reducing work on the programmer's part.
Proposal
This proposal arose from the extensive discussion carried out in issue #9. That issue was originally suggesting a run-time method for extending the prototypes of objects, however, due to the name of the issue, C# extension methods were brought up time and time again. Because this suggestion is for a compile-time abstraction for associating behavior to types, I decided to make a new issue for it, but a lot of the problems and solutions discussed there were considered in this proposal.
Anyways, let's get started.
Case-Functions
Case-functions are functions that can be called using UFCS with the
.*
syntax. They have to meet a few special requirements:case
keyword preceding it.any
,void
, ornever
.[function] <identifier>(case <param_identifier>: <type>, ...)
The
case
keyword, when used in this manner, is only available for use on the first parameter of a function; its use anywhere else in the parameter list is an error. It can be thought of as being acase
for pattern-matching on the type of the first parameter.This creates a special function that can be matched to a type to use with UFCS. Case-functions can be exported by modules directly; they can be later imported and mapped to their types using
with
, explained later.Example:
These functions do have a special type. The above example has the type
(case num: number, min: number, max: number) => number
. This type with thecase
marker is what flags them as usable with the Uniform Function Call Syntax.These functions can be used just like normal functions and can be accepted where ever a compatible type is expected, even assigned to function types that lack
case
in their signature.Casting to a Case-Function
Functions that do not have the case marker can be converted into functions that do by simply assigning them to an identifier with a compatible case-function type.
They still must adhere to the third rule of case-functions, however.
Lambda Case-Functions
The arrow syntax can be used to create case-functions as well.
Case-Functions on Classes
Using
case
in a class method is probably best made illegal, since such methods are intended to work on an instance of an object, usingthis
. However, usingcase
with static class methods should be fine.Rationale for Marking with
case
case
makes it easier for the compiler to pick case-functions off of objects, when usingwith
, to map them to types.Do we Need
case
at All?There are some good arguments that marking functions with
case
is not necessary.with
, the destructuring feature allows you to specify which functions should be mapped. Why not just use this to filter out unwanted functions instead?I fully expect there to be plenty of discussion on whether the introduction of special
case
functions is an unnecessary complexity. Coming from the "extension methods" issue, I err'd on the side of clarity and intent. This proposal will continue to use case-functions, but I will pay close attention to the matter as it develops and amend the proposal if necessary.Alternatives to Keyword
case
I considered a few alternatives to
case
; some of them may be worth arguing for.this
is what is used in C#, however this keyword already has a special meaning in the first parameter of a function. Things would get a lot less clear if the Bind operator::
gets adopted into ECMAScript.with
could also fit, but its already used in this proposal as the keyword that maps case-functions on objects to types.for
has kind of a nice ring to it. It is perhaps understood a little better as the function being "for" a certain type, but it didn't read very well when I was trialing it.extends
was what I was favoring when this was in the context of extension methods, but examining a lot of the discussion over there, I was worried this keyword would confuse people into thinking it somehow actually changes the object or its prototype.A Different Flavor Method: The
.*
OperatorWe can resolve a LOT of the problems that @RyanCavanaugh listed in issue #9 simply by making uniform function calls taste different from normal methods. What do I mean by that? I'd seen it talked about before to use some special access operator to tell the compiler we're after a special kind of method.
<expression>.*<case-method>
Something like
.*
would be a good way to differentiate them. Properties with asterisks in them can't be accessed with the dot-operator, so ambiguity with existing properties and methods is impossible. It also adds the feature without any possibility of breaking existing code.The
.*
operator can call any case-functions that are presently in scope, as well as any compatible case-functions that were mapped to types usingwith
. It can only be used to call case-functions; trying to use it like a property access is an error.Example:
Structural Type Matching
Free-floating case-functions are matched to types structurally, just as TypeScript does everywhere else. If a type is assignable to the first parameter of such a case-function, it can be called with
.*
.There is an exception to this rule in the case of case-methods that were mapped in an explicitly typed manner; more on that later.
Use with
any
Type ProhibitedTraditionally,
any
is thought to be the exit out of the type-system, and because UFCS requires type information to work, an attempt to use.*
with a type on the left-side that isany
should always be an error. Using thenoImplicitAny
compiler flag would probably benefit use of UFCS a lot.It is still possible to associate a case-function to all types without leaving the type-system; the
{}
type matches pretty much all types that are notvoid
ornever
. This can be used in its place.Rationale for
.*
Excessive discussion in issue #9 circled around how compile-time abstractions for methods using only the dot-operator would cause immense confusion in JavaScript programmers, breaking the contract that if an object appears to have a method, then it should not block its assignment to a compatible type. The fear was, "extension methods" ala C# would fool people into thinking the objects were being extended in run-time, causing compiler-bug issues to pour in for the TypeScript team to deal with.
For a lot of people, this potential for confusion is a non-starter. The use of a special syntax should remove the potential for confusion.
The use of
.*
also has a social engineering goal: two examples ago, I useddoubleIt
as an example with a type-method and a case-function sharing the same name. People would hopefully think ofdoubleIt
as one method on an object and*doubleIt
as a different method, in the same way that bangs and question-marks denote different methods in, say, Ruby. People would understand more readily that UFCS "methods" can not fix objects to fit certain types, since types cannot have bare methods with asterisks leading them.I can also see IntelliSense features in IDEs being designed so that when a
.
is typed after an identifier, it would include UFCS options as being methods that have names that start with*
. This marker would serve as a strong indicator to the programmer where the call will end up.Alternatives to
.*
.#
creates probably some of the better looking fake names for case-methods:#doubleIt
or#add
. However, it may complicate implementation of the Preprocessor Directives being proposed in issue Proposal: Preprocessor Directives #4691. Technically, it should not conflict, but I decided to avoid potential interactions with other proposals where possible. I personally like this over.*
..&
seems like a good choice as well. Because an ampersand denotes joining, when used likemyObj.&myFunc
, it almost reads like "myObj joined to myFunc as a method".->
was kicked around as an option in issue Suggestion: Extension methods #9. I don't like it very much because it is similar to both the lambda operator=>
and the greater-than comparator>
; the greater-than symbol is already a pretty loaded symbol. However, it is reminiscent of C++'s member access operator.Mapping Methods
Case-functions will often find their way onto objects, whether as imports from another module or assigned to an object as a means of grouping functions with a similar concern or purpose. Since they are not free-floating inside of local identifiers and extracting each function into its own identifier would be tedious, there needs to be some way to tell the compiler to map them from the object on to compatible types.
with <object>
It is illegal to use
with
in any location except the module's root scope; mappings will exist only within the module they were declared in, including all child scopes of the module. Also,with
only accepts objects that reside in readonly identifiers, such as those made withimport
orconst
, in order to prevent run-time changes from breaking their look-up later.The compiler will examine the type of the object being given to
with
, finding any case-functions and mapping them on to types for use with.*
within the current module.The best use of this is to import case-functions that other modules exported.
Mapping Literals
Object literals can be mapped directly, as shown:
The compiler will need to create a variable to store the object literal so it can be accessed during run-time.
Destructuring Mapping
with <destructured-properties> from <object>
It may sometimes be desired not to map all case-functions in an object. In these cases, case-functions can be picked out using destructuring with
from
.If an attempt to map a case-function to an incompatible type is made using destructuring, the compiler should report it as an error.
Mapping Explicitly to Types
Structural typing does create a problem though. What happens when we have the following?
Technically,
IVector3D
is assignable toIVector2D
, and so this type would receive methods intended for 2D operations (and potentially 3D as well). For this reason, it is important to have a method of opting out of structural typing.with <object> on <type>
The keyword
on
limits the mapping to a specific type; it does not use structural typing to resolve mappings.This seems cumbersome and limiting, but it isn't all that bad. There are lots of ways to be explicit about the type.
There is one other minor problem, and that is using explicit mapping with types that have an exactly identical structure.
These two types are structurally identical, so it might be a good exception to the explicit type rule. Case-functions mapped to
IVector2D
can also appear onVectorLike
as well. This would help out a lot with cross-library compatibility, where one library may call a particular structure by one name and another calls it by another, but the structures are otherwise identical.In the end, I suspect that mapping on explicit types would be the 'best practice' for using UFCS.
Mapping to Generics
Case-functions can support generics, just like any other function. There are typically two ways to map to them.
Mapping without Type Parameters
Mapping without type-parameters will map the function to any type that satisfies the type parameters of the function.
Example:
Mapping with Type Parameters
Providing type-parameters will reduce what types it will appear on, as expected.
Example:
Potential Point of Contention
A
with ... on
is supposed to map to types explicitly. What should happen in the following situation?Technically, the object-literal type
{ x: number, y: number }
does implement ourIVector2D
interface, and so qualifies for theT extends IVector2D
constraint, but it does not do so explicitly.In this case, this proposal recommends using only structural-typing when checking for type-constraints on generics. Therefore, the above will work without errors.
Advanced Usage: Complete Abstracted Types with
interface
The
with
andinterface
keywords can be combined to create compile-time abstract types that have both structure and behavior associated with them.This eliminates the need to use wrapper objects or DSLs to associate behavior to objects represented by interfaces, reducing work on a programmer juggling many similar types entering their program's domain and reducing run-time complexity.
An alternative syntax could potentially be introduced to give the structural side of things top-billing, including multiple mappings to the type:
This might be a more comfortable way to order things, since it focuses on the creation of the new type, with the type sort of "inheriting" its behavior.
There is a point of confusion however: the behavior is still scoped to the module. If this interface was exported, the mapped behavior would be lost. This might make supporting this kind of syntax unworkable or confusing. For this reason, this proposal does not currently recommend implementation of this syntax, but it is still worthy of discussion.
Ambiguity Resolution
Using
with
, it becomes possible for two similar case-functions to be mapped to the same method name. In that case, I think taking a cue from C#'s ambiguity resolution rules, pertaining to extension methods, is a good idea, since they have a good sense to them.Compiling
Compiling UFCS is straight forward, just a simple call-site rewrite. Matters are a little different when function mapping or chaining is involved, but not by very much.
Free-Floating Case-Functions
Free-floating case-functions called with UFCS would not require much effort; just simply replace the method call with a function call.
TypeScript:
Emitted JavaScript:
Mapped Case-Functions
In the case of mapped functions, the compiler will replace the call with a method call to the object that contains the function that was mapped.
TypeScript:
Emitted JavaScript:
Compile Cleanly when Chained
The end result in JavaScript should be easy to follow, especially when UFCS calls are chained. If we were to take the following code:
You would probably prefer it to compile into JavaScript across multiple lines, to maintain readability, something like:
The alternative would become quite nasty rather quickly.
Proposal Weaknesses
While I tried to be as exhaustive as possible, there are just some things I have limited familiarity with. I mention these below so they may be considered by others.
Definition Files
I'm afraid I've never made a complete definition file for a library before, so I don't really know if this proposal presents any challenges in that domain. On surface examination, I don't believe it will, but I would appreciate verification from someone more knowledgeable.
JSX
Another feature of TypeScript I have not used yet. I have no clue how this feature would work with JSX or if it could benefit from it at all. I also don't know if it threatens compatibility or could generate confusion within JSX.
Compiler Complexity?
While the emitted JavaScript is clean and straight-forward, I am not sure how complex implementation of the feature is in the current compiler. It would require the compiler do book-keeping on in-scope case-functions as well as track the mapping of objects and their case-functions to types.
The text was updated successfully, but these errors were encountered: