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

Comparison with Facebook Flow Type System #1265

Closed
fdecampredon opened this issue Nov 25, 2014 · 31 comments
Closed

Comparison with Facebook Flow Type System #1265

fdecampredon opened this issue Nov 25, 2014 · 31 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@fdecampredon
Copy link

Disclaimer: This issue has not for purpose to prove that flow is better or worse than TypeScript, I don't want to criticize the amazing works of both team, but to list the differences in Flow and TypeScript type system and try to evaluate which feature could improve TypeScript.

Also I won't speak about missing features in Flow since the purpose is as stated to improve TypeScript.
Finally this topic is only about type system and not about supported es6/es7 features.

mixed and any

From the flow doc :

  • mixed: the "supertype" of all types. Any type can flow into a mixed.
  • any: the "dynamic" type. Any type can flow into any, and vice-versa

Basically that's mean that with flow any is the equivalent of TypeScript any and mixed is the equivalent of TypeScript {}.

The Object type with flow

From flow doc :

Use mixed to annotate a location that can take anything, but do not use Object instead! It is confusing to view everything as an object, and if by any chance you do mean "any object", there is a better way to specify that, just as there is a way to specify "any function".

With TypeScript Object is the equivalent of {} and accept any type, with Flow Object is the equivalent of {} but is different than mixed, it will only accepts Object (and not other primitive types like string, number, boolean, or function).

function logObjectKeys(object: Object): void {
  Object.keys(object).forEach(function (key) {
    console.log(key);
  });
}
logObjectKeys({ foo: 'bar' }); // valid with TypeScript and Flow
logObjectKeys(3); // valid with TypeScript, Error with flow

In this example the parameter of logObjectKeys is tagged with type Object, for TypeScript that is the equivalent of {} and so it will accept any type, like a number in the case of the second call logObjectKeys(3).
With Flow other primitive types are not compatible with Object and so the type-checker will report and error with the second call logObjectKeys(3) : number is incompatible with Object.

Type are non-null

From flow doc :

In JavaScript, null implicitly converts to all the primitive types; it is also a valid inhabitant of any object type.
In contrast, Flow considers null to be a distinct value that is not part of any other type.

see Flow doc section

Since the flow doc is pretty complete I won't describe this feature in details, just keep in mind that it's forcing developer to have every variables to be initialized, or marked as nullable, examples :

var test: string; // error undefined is not compatible with `string`
var test: ?string;
function getLength() {
  return test.length // error Property length cannot be initialized possibly null or undefined value
}

However like for TypeScript type guard feature, flow understand non-null check:

var test: ?string;
function getLength() {
  if (test == null) {
    return 0;
  } else {
    return test.length; // no error
  }
}

function getLength2() {
  if (test == null) {
    test = '';
  }
  return test.length; // no error
}

Intersection Type

see Flow doc section
see Correspondin TypeScript issue #1256

Like TypeScript flow support union types, it also support a new way of combining types : Intersection Types.
With object, intersection types is like declaring a mixins :

type A = { foo: string; };
type B = { bar : string; };
type AB = A & B;

AB has for type { foo: string; bar : string;};

For functions it is equivalent of declaring overload :

type A = () => void & (t: string) => void
var func : A;

is equivalent to :

interface A {
  (): void;
  (t: string): void;
}
var func: A

Generic resolution capture

Consider the following TypeScript example:

declare function promisify<A,B>(func: (a: A) => B):   (a: A) => Promise<B>;
declare function identity<A>(a: A):  A;

var promisifiedIdentity = promisify(identity);

With TypeScript promisifiedIdentity will have for type:

(a: {}) => Promise<{}>`.

With flow promisifiedIdentity will have for type:

<A>(a: A) => Promise<A>

Type inference

Flow in general try to infer more type than TypeScript.

Inference of parameters

Let's give a look at this example :

function logLength(obj) {
  console.log(obj.length);
}
logLength({length: 'hello'});
logLength([]);
logLength("hey");
logLength(3);

With TypeScript, no errors are reported, with flow the last call of logLength will result in an error because number does not have a length property.

Inferred type changes with usage

With flow unless you expressly type your variable, the type of this variable will change with the usage of this variable :

var x = "5"; // x is inferred as string
console.log(x.length); // ok x is a string and so has a length property
x = 5; // Inferred type is updated to `number`
x *= 5; // valid since x is now a number

In this example x has initially string type, but when assigned to a number the type has been changed to number.
With typescript the assignation x = 5 would result in an error since x was previously assigned to string and its type cannot change.

Inference of Union types

Another difference is that Flow propagates type inference backwards to broaden the inferred type into a type union. This example is from facebook/flow#67 (comment)

class A { x: string; }
class B extends A { y: number; }
class C extends A { z: number; }

function foo() {
    var a = new B();
    if (true) a = new C(); // TypeScript reports an error, because a's type is already too narrow
    a.x; // Flow reports no error, because a's type is correctly inferred to be B | C
}

("correctly" is from the original post.)
Since flow detected that the a variable could have B type or C type depending of a conditional statement, it is now inferred to B | C, and so the statement a.x does not result in an error since both types has an x property, if we would have tried to access the z property and error would have been raised.

This means the following will compile too.

var x = "5"; // x is inferred as string
if ( true) { x = 5; } // Inferred type is updated to string | number
x.toString(); // Compiles
x += 5; // Compiles. Addition is defined for both string and number after all, although the result is very different

Edit

  • Updated the mixed and any section, since mixed is the equivalent of {} there is no need for example.
  • Added section for the Object type.
  • Added section on type inference

Feel free to notify if I forgot something I'll try to update the issue.

@RyanCavanaugh
Copy link
Member

This is interesting and a good starting point for more discussion. Do you mind if I make some copyediting changes to the original post for clarity?

@RyanCavanaugh
Copy link
Member

Unexpected things in Flow (will be updating this comment as I investigate it more)

Odd function argument type inference:

/** Inference of argument typing doesn't seem
    to continue structurally? **/
function fn1(x) { return x * 4; }
fn1('hi'); // Error, expected
fn1(42); // OK

function fn2(x) { return x.length * 4; }
fn2('hi'); // OK
fn2({length: 3}); // OK
fn2({length: 'foo'}); // No error (??)
fn2(42); // Causes error to be reported at function definition, not call (??)

No type inference from object literals:

var a = { x: 4, y: 2 };
// No error (??)
if(a.z.w) { }

@fdecampredon
Copy link
Author

This is interesting and a good starting point for more discussion. Do you mind if I make some copyediting changes to the original post for clarity?

Feel free Like I said the purpose is to try investing flow type system to see if some features could fit in TypeScript one.

@fdecampredon
Copy link
Author

@RyanCavanaugh I guess the last example :

var a = { x: 4, y: 2 };
// No error (??)
if(a.z.w) { }

Is a bug a related to their null check algorithm, I'll report it.

@DanielRosenwasser DanielRosenwasser changed the title Comparaison with Facebook Flow Type System Comparison with Facebook Flow Type System Nov 25, 2014
@Davidhanson90
Copy link

Is

type A = () => void & (t: string) => void
var func : A;

Equivalent to

Declare A : () => void | (t: string) => void
var func : A;

Or could it be?

@fdecampredon
Copy link
Author

@Davidhanson90 not really :

declare var func: ((t: number) => void) | ((t: string) => void)

func(3); //error
func('hello'); //error

in this example flow is unable to know which type in the union type func is so it reports error in both case

declare var func: ((t: number) => void) & ((t: string) => void)

func(3); //no error
func('hello'); //no error

func has both type so both call are valid.

@RyanCavanaugh
Copy link
Member

Is there any observable difference between {} in TypeScript and mixed in Flow?

@fdecampredon
Copy link
Author

@RyanCavanaugh I don't really know after thought I think it's pretty much the same still thinking about it.

@danquirk danquirk added the Question An issue which isn't directly actionable in code label Nov 25, 2014
@Arnavion
Copy link
Contributor

mixed has no properties, not even the properties inherited from Object.prototype that {} has ( #1108 ) This is wrong.

@Arnavion
Copy link
Contributor

Another difference is that Flow propagates type inference backwards to broaden the inferred type into a type union. This example is from facebook/flow#67 (comment)

class A { x: string; }
class B extends A { y: number; }
class C extends A { z: number; }

function foo() {
    var a = new B();
    if (true) a = new C(); // TypeScript reports an error, because a's type is already too narrow
    a.x; // Flow reports no error, because a's type is correctly inferred to be B | C
}

("correctly" is from the original post.)

This means the following will compile too.

var x = "5"; // x is inferred as string
if ( true) { x = 5; } // Inferred type is updated to string | number
x.toString(); // Compiles
x += 5; // Compiles. Addition is defined for both string and number after all, although the result is very different

Edit: Tested the second snippet and it really does compile.
Edit 2: As pointed out by @fdecampredon below, the if (true) { } around the second assignment is required to have Flow infer the type as string | number. Without the if (true) it is inferred as number instead.

@danquirk
Copy link
Member

Do you like this behavior? We went down this route when we discussed union types and the value is dubious. Just because the type system now has the ability to model types with multiple possible states doesn't mean it's desirable to use those everywhere. Ostensibly you have chosen to use a language with a static type checker because you desire compiler errors when you make mistakes, not just because you like writing type annotations ;) That is to say, most languages give an error in an example like this (particularly the second one) not for lack of a way to model the type space but because they actually believe this is a coding error (for similar reasons many eschew supporting lots of implicit cast/conversion operations).

By the same logic I would expect this behavior:

declare function foo<T>(x:T, y: T): T;
var r = foo(1, "a"); // no problem, T is just string|number

but I really do not want that behavior.

@fdecampredon
Copy link
Author

@danquirk I agree with you that inferring union type automatically instead of reporting an error is not a behavior that I like.
But I think that comes from the flow philosophy, more than a real language, the flow team tries to create simply a type checker, their ultimate goal is to be able to make 'safer' code without any type annotations. This lead to be less strict.

@danquirk
Copy link
Member

The exact strictness is even debatable given the knock on effects of this sort of behavior. Often it's just postponing an error (or hiding one entirely). Our old type inference rules for type arguments very much reflected a similar philosophy. When in doubt, we inferred {} for a type parameter rather than make it an error. This meant you could do some goofy things and still do some minimal set of behaviors safely on the result (namely things like toString). The rationale being some people do goofy things in JS and we should try to allow what we can. But in practice the majority of inferences to {} were actually just errors, and making you wait until the first time you dotted off a variable of type T to realize it was {} (or likewise an unexpected union type) and then trace backwards was annoying at best. If you never dotted off it (or never returned something of type T) you didn't notice the error at all until runtime when something blew up (or worse, corrupted data). Similarly:

declare function foo(arg: number);
var x = "5";
x = 5;
foo(x); // error

What's the error here? Is it really passing x to foo? Or was it re-assigning x a value of a completely different type than it was initialized with? How often do people really intentionally do that sort of re-initialization vs accidentally stomping on something? In any case, by inferring a union type for x can you really say the type system was less strict overall if it still resulted in a (worse) error? This sort of inference is only less strict if you never do anything particularly meaningful with the resulting type, which is generally pretty rare.

@fdecampredon
Copy link
Author

Arguably leaving null and undefined assignable to any type hide errors in the same way, most of the time a variable typed with some type and hiding a null value will lead into an error at runtime.

@Arnavion
Copy link
Contributor

A not-insignificant part of Flow's marketing is based around the fact that their typechecker makes more sense of code in places where TS would infer any. Its philosophy is that you should not need to add annotations to get the compiler to infer types. That's why their inference dial is turned to a much more permissive setting than TypeScript's is.

It comes down to whether someone has the expectation that var x = new B(); x = new C(); (where B and C both derive from A) should compile or not, and if it does, what should it be inferred as?

  1. Shouldn't compile.
  2. Should compile and be inferred as the most derived base type common to the type hierarchies of B and C - A. For the number and string example it would be {}
  3. Should compile and be inferred as B | C.

TS currently does (1) and Flow does (3). I prefer (1) and (2) much more over (3).

@fdecampredon
Copy link
Author

I wanted to add @Arnavion examples to the original issue but after playing a bit I realized that things where stranger than what we understood.
In this example :

var x = "5"; // x is inferred as string
x = 5; // x is infered as number now
x.toString(); // Compiles, since number has a toString method
x += 5; // Compiles since x is a number
console.log(x.length) // error x is a number

Now :

var x = '';
if (true) {
  x = 5;
}

after this example x is string | number
And if I do :

1. var x = ''; 
2. if (true) {
3.  x = 5;
4. }
5. x*=5;

I got an error at line 1 saying : myFile.js line 1 string this type is incompatible with myFile.js line 5 number

I still need to figure the logic here ....

@fdecampredon
Copy link
Author

There is also an interesting point about flow that I forgot :

function test(t: Object) { }

test('string'); //error

Basically 'Object' is non compatible with other primitive type, I think that one make sense.

@Igorbek
Copy link
Contributor

Igorbek commented Nov 27, 2014

The 'Generic resolution capture' is definitely must-have feature for TS!

@Arnavion
Copy link
Contributor

@fdecampredon Yes you're right. With var x = "5"; x = 5; x's inferred type is updated to number. By adding the if (true) { } around the second assignment, the typechecker is tricked into assuming that either assignment is valid, which is why the inferred type is updated to number | string instead.

The error you get myFile.js line 1 string this type is incompatible with myFile.js line 5 number is correct, since number | string does not support the * operator (the only operations allowed on a union type are the intersection of all operations on all types of the union). To verify this, you can change it to x += 5 and you'll see it compiles.

I've updated the example in my comment to have the if (true)

@fdecampredon
Copy link
Author

The 'Generic resolution capture' is definitely must-have feature for TS!

+1

@spion
Copy link

spion commented Nov 29, 2014

@Arnavion, not sure why you would prefer {} over B | C. Inferring B | C widens the set of programs that typecheck without compromising correctness which afaik is a generally desirable property of type systems.

The example

declare function foo<T>(x:T, y: T): T;
var r = foo(1, "a"); // no problem, T is just string|number

already typechecks under the current compiler, except T is inferred to be {} rather than string | number. This doesn't compromise correctness but broadly speaking its less useful.

Infering number | string instead of {} doesn't seem problematic to me. In that particular case it doesn't widen the set of valid programs, however if the types share structure, the type system realizing that and making a few extra methods and/or properties valid seems only like an improvement.

@Arnavion
Copy link
Contributor

Inferring B | C widens the set of programs that typecheck without compromising correctness

I think that allowing the + operation on something that can be either a string or a number is compromising correctness, since the operations are not similar to each other at all. It's not like the situation where the operation belongs on a common base class (my option 2) - in that case you can expect some similarity.

@spion
Copy link

spion commented Nov 29, 2014

The + operator would not be callable, as it would have two incompatible overloads - one where both arguments are numbers, and one where both are strings. Since B | C is narrower than both string and number, it would not be allowed as an argument in either overload.

Except functions are bivariant wrt their arguments so that might be a problem?

@Arnavion
Copy link
Contributor

I thought that since var foo: string; console.log(foo + 5); console.log(foo + document); compiles that the string + operator allowed anything on the right side, so string | number would have + <number> as a valid operation. But you're right:

error TS2365: Operator '+' cannot be applied to types 'string | number' and 'number'.

@jods4
Copy link

jods4 commented Jan 13, 2015

A lot of the comments have focused on the automatic broadening of types in Flow. In both cases you can have the behavior you want by adding an annotation. In TS you would broaden explicitely at declaration: var x: number|string = 5; and in Flow you would restrict at declaration: var x: number = 5;. I think the case that doesn't require a type declaration should be the one people use most often. In my projects I would expect var x = 5; x = 'five'; to be an error more often than a union type. So I'd say TS got the inference right on this one.

As for the Flow features that I think are the most valuable?

  1. Non-null types
    I think this one has a very high potential of reducing bugs. For compatibility with existing TS definitions, I imagine it more as a non-null modifier string! rather than Flow's nullable modifier ?string. I see three issues with this:
    How to handle class members initialization? (probably they have to be assigned in the ctor and if they can escape the ctor before assignment they are considered nullable)
    How to handle undefined? (Flow side-steps this issue)
    Can it work without lots of explicit type declarations?
  2. Difference between mixed and Object.
    Because unlike C# primitive types can't be used everywhere an object can. Try Object.keys(3) in your browser and you'll get an error. But this is not critical as I think edge cases are few.
  3. Generic resolution capture
    The example just makes sense. But I can't say that I'm writing a lot of code that would benefit from that. Maybe it'll help with functional libraries such as Underscore?

@hraban
Copy link

hraban commented Jan 23, 2015

On the automatic union type inference: I assume "type inference" to be restricted to the type declaration. A mechanism that implicitly infers an omitted type declaration. Like := in Go. I'm no type theorist, but as far as I understand, type inference is a compiler pass that adds an explicit type annotation to every implicit variable declaration (or function argument), inferred from the type of the expression it is being assigned from. As far as I know, this is how it works for.. well.. every other type inference mechanism out there. C#, Haskell, Go, they all work this way. Or not?

I understand the argument about letting real-life JS use dictate TS semantics, but this is perhaps a good point to follow other languages on, instead. Types are the one single defining difference between JS and TS, after all.

I like a lot of the Flux ideas, but this one, well, if it's actually done this way... that's just weird.

@AlexGalays
Copy link

Non-null types seem like a mandatory feature for a modern type system. Would it be easy to add to ts?

@danquirk
Copy link
Member

If you want some light reading on the complexities of adding non-nullable types to TS see #185

Suffice to say, as nice as non-nullable types are the vast majority of popular languages today do not have non-nullable types by default (which is where the feature truly shines) or any generalized non-nullability feature at all. And few, if any, have attempted to add it (or successfully added it) after the fact due to the complexity and the fact that so much of the value of non-nullability lies in it being the default (similar to immutability). This is not to say we aren't considering the possibilities here, but I wouldn't call it a mandatory feature either.

@fdecampredon
Copy link
Author

Actually as much as I miss non-null type, the real feature I miss from flow are generic capture, the fact that ts resolve every generic to {} makes it really hard to use with some functional construct, especially currying.

@tel
Copy link

tel commented May 9, 2015

Personally, generic capture and non-nullability are high value targets from Flow. I'll read the other thread, but I wanted to throw my 2c in here as well.

I sometimes feel that the benefit of adding non-nullability is worth nearly any cost. It is such a high likelihood error condition and while having default nullability weakens the built-in value right now TypeScript lacks the ability to even discuss nullability by simply assuming it's the case everywhere.

I would annotate every variable I could find as non-nullable in a heartbeat.

@HerringtonDarkholme
Copy link
Contributor

There are quite a lot hidden features in flow, not documented in flow's site. Including SuperType bound and existential type

http://sitr.us/2015/05/31/advanced-features-in-flow.html

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests