Inadequacies of typed JavaScript
Some things can’t really be fixed :-(
— 6 minThe last week I have been playing extensively with both flow and TypeScript. And I have noticed two things that I consider to be bugs in both of them. So is there something wrong with my expectations maybe? Lets analyze both of the cases.
# non-nullable class members
TypeScript (TS) introduces non-nullable types with version 2, and flow has had them for quite a while I think. There have been tons of stories about how bad the null type/value is. Some call it the worst mistake in computer science. And indeed, most of the errors we have at runtime are related to null. Accessing properties of null, or calling null. Having to check values for null all the time is tedious and as the runtime errors prove, null can slip through at all kinds of places.
It is even worse in JavaScript because it does not have one null type but
actually two: null
and undefined
. To make things worse typeof null === "object"
and typeof undefined === "undefined
, so you can’t even check for
both cases at the same time; well actually you can use value == null
since
that also works for undefined. Go figure.
To make more worse still, JS also has boolean coercion. Which itself can be
convenient, but you can very easily trip up. If you have a value that has the
type number | null
, using a simple if (value)
will fail for the value 0
.
Probably not the thing you intended. And you will get it wrong at some point.
I have done so over and over again.
So, to come back to the topic at hand. Both flow and TS now prevent you from tripping up on most of the null issues, except for the boolean coercion. But both of them get one case wrong:
class A {
prop: string;
}
const a = new A();
a.prop.length; // typechecks just fine, fails at runtime
This simple code will typecheck just fine in both flow and TS, but will fail at runtime with a TypeError because you are accessing a property on null. I have reported this on the TS issue tracker but it was closed as a duplicate of a wontfixed issue. Apparently it is too difficult to correctly cover all the different ways in which constructors can behave.
As far as I see it, the root of the problem is twofold.
First, JS objects were never meant to have a guaranteed shape, its only these
type checkers that kind of try to enforce those things. And the second problem
is that JS constructors work by assigning/creating properties on this
, which
can be aliased or delegated to other functions, etc…
In other languages with stricter guarantees, such as Rust, you do not have
this
or constructor functions at all. The language itself guarantees that
creating an object of a certain type is atomic via an object literal that is
guaranteed to include all properties. (You can still use object spread for
convenience)
# Ideas to solve the Problem
Both flow and TS can hold up the typesystem guarantees if you are using object
literals instead of classes. Use plain functions and object instead of classes.
I have heard that one before. The whole functional programming crowd advocates
this. Maybe they have a point? But I do like having methods on
objects/prototypes. So maybe we can combine object literals that are correctly
checked for null properties with methods somehow. Well there is this special
__proto__
property that was specified for ES2015. I tried using it with TS,
but failed. The method works perfectly in JS, but typechecking in TS fails.
Well unless I want to copy every method into the object, which completely
defeats the purpose of shared prototype methods.
type A = {
a: string;
};
function A(a: string): A {
return {
__proto__: AProto, // can’t assign to unknown property
a
};
}
const AProto = {
method(this: A) {
return this.a.length;
}
};
const a = A("a");
a.method(); // can’t call unknown method
So no luck here :-(
# Aliasing literal / union types
Both flow and TS have the notion of literal types and union types. Union types
are really simple. The type A | B
can either be A
or B
. But since those
are not native to JS, you have to have some way to actually assert the type at
runtime. For primitives its easy, since you can just check via typeof
. But
discriminating objects needs to be done manually with a property used to tag
the object. In the following example, U
is such a union type and it can be
discriminated by looking at the type
property. And here we are actually using
strings as types: literal types. So in this case U.type
has the type "a" | "b"
, so it can either be the string "a"
or "b"
, but nothing else.
type A = { type: "a"; propA: string };
type B = { type: "b"; propB: number };
type U = A | B;
Both flow and TS handle these things quite well. If you use .type
in a
switch
or if
statement, you can match for the exact type. But both
typecheckers fail if you want to alias that property, for example via
destructuring: const {type} = X;
. Here, the type of type
is widened to
string
and it can no longer be used to discriminate the union type.
I also reported this in the
TS issue tracker and
got the answer that is would be two complicated to implement this, because it
would involve tracking the dependency between the two variables, increasing the
depth of the dependency as more aliases are added.
Well at least this can be easily fixed by just always using the property instead of extracting it into a local.
# Closing remarks
I wish one of the compile-to-js languages would actually support real union
types and has a real match expression. Automatically tagging variants or even
supporting null-pointer optimization so you don’t have to have unnecessary
indirections if a simple typeof
check would be enough.
There are already compile-to-js languages that support things like this. For Example Elm and PureScript (both based on Haskell). And possibly ClojureScript, even though I know too little about that language. With the recently released BuckleScript, you can also compile OCaml to JS, with quite readable code, although I don’t quite like that it encodes structures as arrays and thus misses out on hidden class optimizations of JS engines (I could be wrong though). With Reason you can even write readable OCaml.
And of cause, I am very excited to see steady, although very slow progress
towards compiling Rust to WebAssembly. Although I’m unsure about the FFI
between WebAssembly/asm.js and real JS. It relies on a preallocated
ArrayBuffer
as a kind of heap, which might be good for low level performance
but does not play well with consuming and outputting plain JS objects.
So the future is still very much open. I really hope there will be a readable, convenient, fast and safe compile-to-js language that can solve all these problems and integrates seamlessly into existing JS projects. One can dream though.