Comparison with Facebook Flow Type System
fdecampredon opened this issue · 31 comments
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
andany
section, sincemixed
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.
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?
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) { }
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.
@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.
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?
@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.
Is there any observable difference between {}
in TypeScript and mixed
in Flow?
@RyanCavanaugh I don't really know after thought I think it's pretty much the same still thinking about it.
This is wrong.mixed
has no properties, not even the properties inherited from Object.prototype that {}
has ( #1108 )
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.
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.
@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.
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.
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.
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?
- Shouldn't compile.
- 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 {}
- 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).
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 ....
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.
The 'Generic resolution capture' is definitely must-have feature for TS!
@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)
The 'Generic resolution capture' is definitely must-have feature for TS!
+1
@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.
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.
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?
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'.
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?
- 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 modifierstring!
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 handleundefined
? (Flow side-steps this issue)
Can it work without lots of explicit type declarations? - Difference between
mixed
andObject
.
Because unlike C# primitive types can't be used everywhere an object can. TryObject.keys(3)
in your browser and you'll get an error. But this is not critical as I think edge cases are few. - 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?
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.
Non-null types seem like a mandatory feature for a modern type system. Would it be easy to add to ts?
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.
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.
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.
There are quite a lot hidden features in flow, not documented in flow's site. Including SuperType bound and existential type