Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Uniform Function Call Syntax (...sort of) #12630

Closed
JHawkley opened this issue Dec 3, 2016 · 2 comments
Closed

Proposal: Uniform Function Call Syntax (...sort of) #12630

JHawkley opened this issue Dec 3, 2016 · 2 comments
Labels
Out of Scope This idea sits outside of the TypeScript language design constraints Suggestion An idea for TypeScript

Comments

@JHawkley
Copy link

JHawkley commented Dec 3, 2016

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 and interface 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:

  1. They must have at least one parameter.
  2. The first parameter must have the case keyword preceding it.
  3. The first parameter cannot have the types any, void, or never.

[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 a case 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:

function clamp(case num: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, num));
}

let n = 192.*clamp(0, 100);

These functions do have a special type. The above example has the type (case num: number, min: number, max: number) => number. This type with the case 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.

clamp(10, 5, 10); // OK!
let fn: (num: number, min: number, max: number) => number = clamp;
fn(5, 0, 10); // OK!

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.

let myDouble = (num: number) => num * 2;
let caseDouble: (case num: number) => number = myDouble;

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.

let double = (case num: Number) => num * 2;

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, using this. However, using case with static class methods should be fine.

Rationale for Marking with case

  1. Marking a function with case makes it easier for the compiler to pick case-functions off of objects, when using with, to map them to types.
  2. It has a clarity of intent that lets other programmers know it is a function meant for UFCS calls.
  3. Gives more control over what free-floating functions can and can't be used with UFCS.

Do we Need case at All?

There are some good arguments that marking functions with case is not necessary.

  • It isn't technically "uniform" if some functions are made non-uniform to be usable with Uniform Function Call Syntax.
  • It's pretty rare that there are free-floating functions in code anyways; why is it necessary to set some functions apart like this?
  • When being pulled from other objects using 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 .* Operator

We 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 using with. It can only be used to call case-functions; trying to use it like a property access is an error.

Example:

class MyClass {
  constructor(public num: number) {}
  doubleIt(): number { return this.num * 2; }
};
// This function would have resulted in ambiguity without the special operator.
function doubleIt(case obj: MyClass): number { return obj.num * 2; }

let n: number;
let myObj = new MyClass(6);
n = myObj.doubleIt();  // Calls the type-method.
n = myObj.*doubleIt();  // Calls the case-method.
n = doubleIt(myObj);  // Also perfectly fine.

let impossibleFn = myObj.*doubleIt;  // Error: case-methods cannot be accessed as properties.

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 .*.

function logIt(case obj: string | number) { console.log(obj); }

// Any object that is assignable to 'string | number' can use 'logIt'.
9.*logIt();
'move that bus!'.*logIt();

let eitherType = Math.random() > 0.5 ? 42 : 'forty-two';
eitherType.*logIt();  // Also fine, because type is 'string | number'.

true.*logIt();  // Error: 'boolean' cannot be assigned to 'string | number'.

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 Prohibited

Traditionally, 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 is any should always be an error. Using the noImplicitAny 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 not void or never. 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 used doubleIt as an example with a type-method and a case-function sharing the same name. People would hopefully think of doubleIt 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 like myObj.&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 with import or const, 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.

const myMethods = {
  double(case num: number): number { return num * 2; },
  square(case num: number): number { return num * num; }
}

// Before mapping, trying to use these would fail.
8.*double(); // Error: no case-method 'double' is mapped to type 'number'.

// But now we can map them out of the object and into the types.
with myMethods;

3.*double(); // Result: 6
3.*square(); // Result: 9

The best use of this is to import case-functions that other modules exported.

import * as VectorMath from 'vector-math';
with VectorMath;

Mapping Literals

Object literals can be mapped directly, as shown:

with {
  double(case num: number): number { return num * 2; },
  square(case num: number): number { return num * num; }
}
3.*double(); // Ok!

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.

const myMethods = {
  double(case num: number): number { return num * 2; },
  square(case num: number): number { return num * num; }
}

// Only map the function called 'square'.
with { square } from myMethods;

3.*double(); // Error: no case-method 'double' is mapped to type 'number'.
3.*square(); // Ok!

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?

interface IVector2D { x: number; y: number; }
interface IVector3D { x: number; y: number; z: number; }

const VMath2D = {
  add(case operandA: IVector2D, operandB: IVector2D): IVector2D {
    /* ... implementation here ... */
  },
  sub(case operandA: IVector2D, operandB: IVector2D): IVector2D {
    /* ... implementation here ... */
  }
}

Technically, IVector3D is assignable to IVector2D, 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.

// Same interfaces and object as above.

// But we're going to be explicit about the type that the functions are mapped to.
// The compiler will only map to objects that explicitly identify as the 'IVector2D' type.
with VMath2D on IVector2D;

let vec2D: IVector2D = { x: 2, y: 4 };
vec2D.*add(vec2D);  // Perfectly fine.  Type of 'vec2D' is explicitly 'IVector2D'.

let vec3D: IVector3D = { x: 6, y: 8, z: 10 };
vec3D.*add(vec3D);  // Error: no case-method 'add' is mapped to type 'IVector3D'.

// But, the type DOES have to be explicitly provided to use the function with UFCS.
let anotherVec2D = { x: 12, y: 14 };
anotherVec2D.*add(vec2D);  // Error: no case-method 'add' is mapped to type '{ x: number, y: number }'.

This seems cumbersome and limiting, but it isn't all that bad. There are lots of ways to be explicit about the type.

// Mapping 'someImport.someFn' to 'Foo' explicitly.
with { someFn } from someImport on Foo;

// Typecasts
(obj as Foo).*someFn();

// Being assigned to an identifier with that type.
let obj: Foo = otherVar;
obj.*someFn();

// Being an argument of that type in a function/method.
function fn(param: Foo) { param.*someFn(); }
fn(obj); // OK!  ...as long as 'obj' is assignable to 'Foo'.

// An interface explicitly extending the type.
interface MyInterface extends Foo { /* ... */ }
let obj: MyInterface = otherVar;
obj.*someFn(); // Ok!  'MyInterface' extends 'Foo'.

// A class that explicitly extends or implements the type, or extends from a class that does.
class MyClass implements Foo { /* ... */ }
let obj = new MyClass();
obj.*someFn();  // Ok!  'MyClass' implements 'Foo'.

There is one other minor problem, and that is using explicit mapping with types that have an exactly identical structure.

interface IVector2D { x: number; y: number }
interface VectorLike { x: number; y: number }

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 on VectorLike 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:

with { filter } from ArrayHelpers on Array;

[1, 2, 3, 4].*filter((item) => item <= 2);
['a', 'b', 'c'].*filter((item) => item === 'c');
Mapping with Type Parameters

Providing type-parameters will reduce what types it will appear on, as expected.

Example:

with { filter } from ArrayHelpers on Array<number>;

[1, 2, 3, 4].*filter((item) => item <= 2); // Ok!
['a', 'b', 'c'].*filter((item) => item === 'c'); // Error!
Potential Point of Contention

A with ... on is supposed to map to types explicitly. What should happen in the following situation?

interface IVector2D { x: number; y: number }
const fnContainer = {
  addToMany<T extends IVector2D>(case arr: Array<T>, operand: IVector2D) { /* ... */ }
}
with fnContainer on Array<{ x: number, y: number }>;

Technically, the object-literal type { x: number, y: number } does implement our IVector2D interface, and so qualifies for the T 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 and interface keywords can be combined to create compile-time abstract types that have both structure and behavior associated with them.

with VMath2D on interface IVector2D { x: number; y: number; }

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:

interface IVector2D with VMath2D, VConversions2D { x: number; y: number };

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.

  1. Try to resolve ambiguity based on the types involved, if possible. This would probably just use the same scheme as determining the signature of an overloaded method.
  2. Free-floating case-functions are preferred next, as they are considered "more relevant" and immediate than mapped functions.
  3. Case-functions that are mapped from objects are next.
  4. If ambiguity still exists, it is a compile-time error. The programmer must find some other way to resolve the ambiguity, such as calling their intended function like a normal function, instead.

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:

function goDriving(case car: Car, route: number, motto: string) { /* drive that car */ }
bigTruck.*goDriving(66, 'rule the road');

Emitted JavaScript:

function goDriving(car, route, motto) { /* drive that car */ }
goDriving(bigTruck, 66, 'rule the road');

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:

const myMethods = {
  double(case num: number): number { return num * 2; },
  square(case num: number): number { return num * num; }
}
with myMethods;
let eightTeen = 9.*double();

Emitted JavaScript:

var myMethods = {
  double: function(num) { return num * 2; },
  square: function(num) { return num * num; }
}
var eightTeen = myMethods.double(9);

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:

let avg = accumulator.*add(v1).*add(v2).*add(v3).*scale(1 / 3);

You would probably prefer it to compile into JavaScript across multiple lines, to maintain readability, something like:

var _chain, avg;
_chain = VMath2D.add(accumulator, v1);
_chain = VMath2D.add(_chain, v2);
_chain = VMath2D.add(_chain, v3);
_chain = VMath2D.scale(_chain, 1 / 3);
avg = _chain;

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.

@alitaheri
Copy link

This goes against typescripts design goals since it add expression level features not specified in ECMAScript. You should start here. If this makes it to stage 3 of ECMAScript proposals then typescript will implement it ahead of time.

@JHawkley
Copy link
Author

JHawkley commented Dec 4, 2016

This could work in ECMAScript, though with some reduced capabilities. I'll consider reworking it for ECMAScript later, but for now, I've spent enough time on this. I need to get back to work.

@JHawkley JHawkley closed this as completed Dec 4, 2016
@RyanCavanaugh RyanCavanaugh added Out of Scope This idea sits outside of the TypeScript language design constraints Suggestion An idea for TypeScript labels Dec 5, 2016
@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Out of Scope This idea sits outside of the TypeScript language design constraints Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants