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

Suggestion: union types #14

Closed
RyanCavanaugh opened this issue Jul 15, 2014 · 23 comments
Closed

Suggestion: union types #14

RyanCavanaugh opened this issue Jul 15, 2014 · 23 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@RyanCavanaugh
Copy link
Member

Many values or parameters in JavaScript can be of more than one type. For example, a function might take an object where one of the properties can be either a string or a number, but not a function.

Comments from Ron Buckton in https://typescript.codeplex.com/workitem/1364
I'd like to see type annotations provide some support for a type union. For example:

ES6 Promise

class Promise<T> {
  // ...
  static any<TResult>(...values: union { Promise<T>; T; }[]): Promise<T>;
  static every<TResult>(...values: union { Promise<T>; T; }[]): Promise<T[]>;
  static some<TResult>(...values: union { Promise<T>; T; }[]): Promise<T>;
  then(resolve?: (value: T) => union { Promise<T>; T; }, reject?: (value: any) => union { Promise<T>; T; }): Promise<T>;
  // ...
}

ES6 Loader

class Loader<T> {
  // ...
  normalize(name: string, referer?: Referer): union { string; { normalized: string; metadata?: any }; };
  resolve(normalized: string, options?: { referer: Referer; metadata: any; }): union { string; { address: string; extra?: string[]; }; };
  // ...
}

When static type checking is performed, it is possible to have some type issues when explicitly providing generic type arguments and having the wrong type chosen, but this exists today without supporting type unions.

The other open issue is what to do with a local that is a type union: should it act as an any, a type that contains all of the members of both (or all) types in the union, or a type that only contains the members that are the same in both (or all) types in the union.

An implicit or explicit type cast from a type union to one of the union types would pass without the need to perform an intermediate cast to , and an explicit type cast to a more specific type for any of the union types would likewise succeed without an intermediate cast.

Assignment to a local or field that is a type union would succeed as if it were either of the types (e.g. implicit or explicit type cast from a more specific type to a less specific type specified in the type union).

There is also a question on how to properly handle the intellisense for a symbol that uses a type union. It could either be represented as a number of overloads (similar to what would have to be typed today), or preserve the type union definition.

Union Declaration

Providing a typedef-like syntax for unions would also be useful to define a reusable definition:

union Ref<T> {
  Promise<T>;
  T;
}

This is roughly analogous to an interface that defines multiple call signatures:

// as an interface with call signatures...
interface ResolveCallback<T> {
  (value: Promise<T>): void;
  (value: T): void;
  (): void;
}

// ...and as a union with function types
union ResolveCallback<T> {
  (value: Promise<T>) => void;
  (value: T) => void;
  () => void;
}

Static Analysis

Adding type unions would require changes to the static type information to be supported. The primary goal of adding type unions is to help the compiler determine the best matching type for a call expression or return type expression. The following sections discuss various ways of handling static analysis of type unions.

Assigning to a Type Union

When assigning to an identifier that is annotated with a type union, passing a value as an argument to a function for a parameter that is a type union, returning a value from a function with a type union in its return type annotation, or type-casting a value to a type union, the type of the value being assigned or returned must be compatible (as either an exact match or a superset of type information) with one of the types defined in the type union.

For example:

// assign to variable
var value: union { Promise<number>; number; };
value = 1; // legal
value = Promise.resolve<number>(1); // legal
value = new Date(); // static error

// type-cast to union
declare var n: number;
declare var p: Promise<number>;
declare var a: any;
declare var d: Date;
<union { Promise<number>; number; }>n; // legal
<union { Promise<number>; number; }>p; // legal
<union { Promise<number>; number; }>a; // legal
<union { Promise<number>; number; }>d; // legal

// union in return value
function func(type: string): union { Promise<number>; number; } {
  switch (type) {
    case "number":
      return 1; // legal

    case "promise":
     return Promise.resolve<number>(1); // legal

    case "date":
     return new Date(); // static error  
  }
}

// union in invocation expression
declare function func(promiseOrValue: union { Promise<number>; number; }): void;
declare var n: number;
declare var p: Promise<number>;
declare var a: any;
declare var d: Date;

func(n); // legal
func(p); // legal
func(a); // legal
func(d); // static error

Assigning from a Type Union

When assigning to another value from a type union or type-casting from a type union, the type of the value must be compatible (as either an exact match or a subset of type information) with one of the types in the union.

For example:

// assignment
declare var value: union { Promise<number>; number; };
var n: number;
var p: Promise<number>;
var a: any;
var d: Date;

n = value; // legal
p = value; // legal
a = value; // legal
d = value; // static error

// type-cast
<Promise<number>>value; // legal
<number>value; // legal
<any>value; // legal
<Date>value; // static error
@johnnyreilly
Copy link

👍

@iislucas
Copy link

From #186; an implementation that leverages the existing lookup power of objects may also ber possible/simpler. Here's what I'm thinking of...

type Animal {
  | Dog;
  | Cat;
  | Bird;
  | Monster { scaryness :number, size :number };
}

That would approximately correspond to a JS object that is assumed to have exactly one of the following parameters, i.e. be treated a bit like:

interface Animal {
  kind_ :string;
  Dog ?:void;
  Cat ?:void;
  Bird ?: void;
  Monster ?: { scaryness :number, size :number, b :number }
}

When you define a variable of this type, e.g.

var monster = Monster {scaryness: 3, size: 2};

it can be compiled like so:

var monster = { kind_: 'Monster', Monster: {scaryness: 3, size: 2} };

Then you can match in a more standard functional programming style of syntax like so:

function logAnimal(animal: Animal) {
  case (animal) {
    | Dog => { console.log('Dog (barks!)'); }
    | Monster(m) => { console.log('Monster is ' + m.scaryness + ' scary'); }
    | _ => { console.log(animal.kind_); }
  };
}

var myMonster :Animal = Animal.Monster { scaryness: 100, size: 5 };
var dog :Animal = Animal.Dog;

logAnimal(myMonster); //should ouput 'Monster is 100 scary'
logAnimal(dog);  //should ouput 'Dog (barks!)'

Which would be compiled to JS like so:

function logAnimal(animal) {
  if(animal.kind_ in animal) {
    var caseSelector_ = {
      'Dog': function() { console.log('Dog (barks!)'); },
      'Monster': function(m) {
          console.log('Monster is ' + m.scaryness + ' scary');
        }
    }
    caseSelector_[animal.kind_](animal[animal.kind_]);
  } else {
    // Default
    console.log(animal.kind_);
  }
}

var myMonster = { kind_: 'Monster', Monster: {scaryness: 100, size: 5} };
var dog = { kind_: 'Dog', Dog: null };

logAnimal(myMonster); //should ouput 'Monster is 100 scary'
logAnimal(dog);  //should ouput 'Dog (barks!)'

That seems to provide reasonable balance of conciseness, readability and efficiency.

@basarat
Copy link
Contributor

basarat commented Jul 29, 2014

👍

1 similar comment
@fsoikin
Copy link

fsoikin commented Jul 29, 2014

+1

@samuelneff
Copy link

👍

1 similar comment
@Evgenus
Copy link

Evgenus commented Aug 8, 2014

+1

@ivogabe
Copy link
Contributor

ivogabe commented Aug 8, 2014

I like the union style that looks like an interface declaration, but inline I'd prefer |:

var value: Promise<number> | number;
// instead of
var value: union { Promise<number>; number; };

@coreh
Copy link

coreh commented Aug 14, 2014

We could have A | B be just a syntax sugar for union { A; B; }.

@rbuckton
Copy link
Member

It's extremely difficult to properly type the constructor for an ES6 Promise. In ES6, the Promise constructor takes a callback that receives two arguments: a resolve function and a reject function. If the resolution value passed to the resolve function is itself a Promise or a "thenable", the final state of the resolution value will be adopted by the new Promise. This is difficult to properly type in TypeScript today:

declare class Promise<T> {
  constructor(init: (resolve: (value?: T) => void, reject: (reason: any) => void) => void);
  constructor(init: (resolve: (value: Promise<T>) => void, reject: (reason: any) => void) => void);
}

With the above, you can use contextual typing for the resolve callback to pick the correct constructor overload, but not type inference:

// `resolve` is contextually typed
// result: ok
new Promise<number>((resolve: (value: number) => void) => {
  resolve(0);
});

// `resolve` is contextually typed
// result: ok
var p: Promise<number>
new Promise<number>((resolve: (value: Promise<number>) => void) => {
  resolve(p);
});

// `resolve` is inferred from the first constructor
// result: ok
new Promise<number>(resolve => {
  resolve(0);
});

// `resolve` is inferred from the first constructor
// result: 
// error TS2082: Supplied parameters do not match any signature of call target:
//    Could not apply type 'number' to argument 1 which is of type 'Promise<number>'
// error TS2087: Could not select overload for 'call' expression
new Promise<number>(resolve => {
  resolve(p);
});

// `resolve` is inferred from the first constructor
// result: 
// error TS2082: Supplied parameters do not match any signature of call target:
//    Could not apply type 'number' to argument 1 which is of type 'Promise<number>'
// error TS2087: Could not select overload for 'call' expression
var a = true;
new Promise<number>(resolve => {
  if (a) {
    resolve(0);
  }
  else {
    resolve(p);
  }
});

As a result, the only way to make type inference work here is to add another overload that uses an inline interface with multiple call signatures:

declare class Promise<T> {
  constructor(init: (
    resolve: {
      (value?: T): void;
      (value: Promise<T>): void;
    }, 
    reject: (reason: any) => void
  ) => void);
  constructor(init: (resolve: (value?: T) => void, reject: (reason: any) => void) => void);
  constructor(init: (resolve: (value: Promise<T>) => void, reject: (reason: any) => void) => void);
}

However, even with the above, you still cannot mix both contextual typing of resolve with the ability to pass either a T or Promise as the first argument, unless you type T as any. Type Unions would make this simpler and much more reliable:

declare class Promise<T> {
  constructor(init: (resolve: (value?: union { T; Promise<T> }) => void, reject: (value: any) => void);
}

@rbuckton
Copy link
Member

@ivogabe, Generally I think having a single syntax would be easier to digest for users in the long run. There was a reason I settled on the union { A; B; } syntax over using A | B, though I can't recall offhand what it was. I was a bit concerned about syntax ambiguity.

@rbuckton
Copy link
Member

Type Unions only go part of the way to solving issues with proper typing for things like ES6 Promises. Due to the fact that ES6 Promises adopt the state of a "thenable" resolution value (in essence, recursively unwrapping "thenables" until reaching a non-"thenable" or a rejection), you can never have an ES6 Promise with the type: Promise<Promise<T>>. It's generally easy to avoid this in TypeScript if you allow the compiler to infer the generic type when calling then, however since any type could be applied for the generic type in then, its a possible footgun for end users who could type this:

// p is typed as a Promise<Promise<number>>, but is really just a Promise<number>
var p = promise.then<Promise<number>>(() => ...);

I had additionally proposed a new constraint not for generics that could solve this:

// ES6 "thenable"
interface Thenable<T not Thenable> {
  then<TResult not Thenable>(
    onFulfilled?: (value: T) => ThenableResult<TResult>,
    onRejected?: (reason: any) => ThenableResult<TResult>
  ): Thenable<TResult>;
}

// ES6 "thenable" result
interface ThenableResult<T not Thenable> extends union { 
  T;
  Thenable<T>;
}

// ES6 Promise
declare class Promise<T not Thenable> implements Thenable<T> {
  constructor(init: (resolve: (value?: union { T; Thenable<T>; }) => void, reject: (reason: any) => void);
  ...
  then<TResult not Thenable>(
    onFulfilled?: (value: T) => ThenableResult<TResult>, 
    onRejected?: (reason: any) => ThenableResult<TResult>
  ): Promise<TResult>;
  ...
}

Here, T can be any value that does not match the interface of Thenable<{}>, which would disallow Promise<Promise<number>> along with Promise<jQuery.Deferred<number>> etc. It would also help when determining the correct overload when performing type analysis.

@acutus
Copy link

acutus commented Sep 8, 2014

+1

1 similar comment
@Thorium
Copy link

Thorium commented Sep 8, 2014

+1

@robertknight
Copy link

The slides at 1:37:00 in this video on Facebook's Flow type checker shows a little into how union types are handled there: https://www.youtube.com/watch?v=Bse0sYR7oVY#t=4139

The Flow type annotations are very similar to TypeScript so it might be a good reference.

The cool part is that you can write regular JavaScript (if (typeof foo == "type")) inside a function taking a union to figure out what the type of the variable is and then Flow can infer the type within a block, so you have the benefits of pattern matching but without having to introduce new language constructs.

@basarat
Copy link
Contributor

basarat commented Sep 23, 2014

@robertknight nice. More direct link : http://youtu.be/Bse0sYR7oVY?t=1h37m7s

@Thorium
Copy link

Thorium commented Sep 23, 2014

But you can create same function in TS with multiple different type syntaxes?
More like Haskell pattern-matching than Caml-way suggested here?

@johnnyreilly
Copy link

Thanks @basarat / @robertknight - nice to see!

@fsoikin
Copy link

fsoikin commented Sep 23, 2014

@Thorium, if you use function overloads for this purpose, then their number explodes exponentially.
Consider this function:

function f( x: string | number; y: bool | string );

And here's the way to do it with overloads:

function f( x: string; y: bool );
function f( x: number; y: bool );
function f( x: string; y: string; );
function f( x: number; y: string );

Add another parameter with two possible types, and you get yourself 8 overloads. Fourth parameter - and it's 16. And so on.

This is equally true if you pack these parameters in an interface - except now you also get to define 4 (or 8, or 16) different interfaces, one for every possible combination of types.

And keep in mind that it is a very common pattern to have option bags where every parameter can have different types. Take jQueryUI's position helper for example - pay attention to the of and within properties.

@robertknight
Copy link

I said above that Flow type annotations were similar to TS. What I should have said is that they are syntactically compatible where the feature sets overlap. Flow also has non-nullable types and unions.

@RyanCavanaugh
Copy link
Member Author

Great discussion. We have a "spec preview" up at #805 that incorporates this feedback and puts up a concrete proposal to work from, so let's continue over there.

@omidkrad
Copy link

FYI, Facebook just announced Flow language. It supports Union Types, and here's an excerpt that differentiates it from TypeScript:

Flow’s type checking is opt-in — you do not need to type check all your code at once. However, underlying the design of Flow is the assumption that most JavaScript code is implicitly statically typed; even though types may not appear anywhere in the code, they are in the developer’s mind as a way to reason about the correctness of the code. Flow infers those types automatically wherever possible, which means that it can find type errors without needing any changes to the code at all. On the other hand, some JavaScript code, especially frameworks, make heavy use of reflection that is often hard to reason about statically. For such inherently dynamic code, type checking would be too imprecise, so Flow provides a simple way to explicitly trust such code and move on. This design is validated by our huge JavaScript codebase at Facebook: Most of our code falls in the implicitly statically typed category, where developers can check their code for type errors without having to explicitly annotate that code with types.

This makes Flow fundamentally different than existing JavaScript type systems (such as TypeScript), which make the weaker assumption that most JavaScript code is dynamically typed, and that it is up to the developer to express which code may be amenable to static typing. In general, such a design leads to reduced coverage: Fewer type errors are caught, and tools are less effective. While this is a reasonable choice for some code, in general such a design does not provide as many benefits as it could without significant additional effort. Still, Flow provides a simple way to switch to this weak mode of type checking where desired, which is typically useful when checking existing code.

@6ix4our
Copy link

6ix4our commented Nov 19, 2014

TypeScript will soon support Union types, soon (http://blogs.msdn.com/b/typescript/archive/2014/11/18/what-s-new-in-the-typescript-type-system.aspx). However, I do appreciate hearing about Flow. In my experience, the type inference of TypeScript’s design-time and compile-time modes is already powerful enough for my needs. In fact, many beneficial features of TypeScript, such as Interfaces, Generics (with constraints), Modules, and Enumerations, along with first-class VisualStudio support, npm/JAKE/Travis CI support, and acceptance by tool-makers, like Telerik, aren’t things I’m likely to find in Flow for a great deal of time. Your observation about established JS libraries requiring “significant additional effort” to statically type is patently correct. Thankfully, there has already been a monumental, and ongoing effort to do just that, with DefinitelyTyped.

The advantage I see with Flow, however, is that it’s exposing more developers to the syntactical conventions established in TypeScript. Above that, my assessment of Flow is that it’s a less-mature version of TypeScript. We may not get full “nullable” type support, but literally everything else is worth the investment into TypeScript. Besides, I think, given that all primitives are nullable anyway, it’s the responsibility of the coder in JS to make value checking a priority, at runtime, where any ambiguity exists (say by a call from an externally written bit of JS code), and at compile time with tests. This seems to strike an ironic chord with the stated purpose of Flow: trusting the developer to write good code.

In any case, I hope both projects help evolve JavaScript beyond ES6 with some common conventions and improved design-time safety, while simultaneously providing a better experience for JS development overall in the near-term.

From: Omid K. Rad
Sent: ‎Tuesday‎, ‎November‎ ‎18‎, ‎2014 ‎12‎:‎10‎ ‎PM
To: Microsoft/TypeScript
Cc: David Berry

FYI, Facebook just announced TypeFlow language, and it supports Union Types. Here's an excerpt that differentiates it from TypeScript:

Flow’s type checking is opt-in — you do not need to type check all your code at once. However, underlying the design of Flow is the assumption that most JavaScript code is implicitly statically typed; even though types may not appear anywhere in the code, they are in the developer’s mind as a way to reason about the correctness of the code. Flow infers those types automatically wherever possible, which means that it can find type errors without needing any changes to the code at all. On the other hand, some JavaScript code, especially frameworks, make heavy use of reflection that is often hard to reason about statically. For such inherently dynamic code, type checking would be too imprecise, so Flow provides a simple way to explicitly trust such code and move on. This design is validated by our huge JavaScript codebase at Facebook: Most of our code falls in the implicitly statically typed category, where developers can check their code for type errors without having to explicitly annotate that code with types.

This makes Flow fundamentally different than existing JavaScript type systems (such as TypeScript), which make the weaker assumption that most JavaScript code is dynamically typed, and that it is up to the developer to express which code may be amenable to static typing. In general, such a design leads to reduced coverage: Fewer type errors are caught, and tools are less effective. While this is a reasonable choice for some code, in general such a design does not provide as many benefits as it could without significant additional effort. Still, Flow provides a simple way to switch to this weak mode of type checking where desired, which is typically useful when checking existing code.


Reply to this email directly or view it on GitHub.

@NoelAbrahams
Copy link

In my opinion, and speaking from the perspective of web/mobile application development, the idea of catching errors introduced by null is not an easy problem to solve.

The example given in @omidkrad's link is the following:

function length(x) {
  return x.length;  // Compile time error, because null is an argument below
}
var total = length('Hello') + length(null);

In the real world, we are more likely to see

function length(x) {
  return x.length;
}

var request = new XMLHttpRequest();

request.onload = function() {

    var obj = JSON.parse(this.responseText);
    length(obj); // The web service may always send a non-null response, or may not
};

request.open("get", "www.example.com/getFoo", true);
request.send();

The resolution of this problem requires knowledge of the web service's policy.

In short, for web/mobile application development, data enters the system either over the wire or from user interaction. The nullability of this data is not easily predictable.

This issue was closed.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests