I can see both sides of the argument when it comes to types. In favor of static types, F# is an amazing language. Due to its strict type system, you’ll rarely have “null” problems and it’s hard to compile a program that isn’t correct. On the other hand, JavaScript’s dynamic and expressive freedom allows me to get work done quickly and at times, effortlessly. This article isn’t about deciding whether you need static types. This article also isn’t intended to sway you one way or the other as to whether you should adopt TypeScript (only you know enough to decide that for your team), but rather to explain my resistance to it when asked.
What TypeScript Does Well
TypeScript has some amazing parts about it. The integration with Microsoft’s IDEs (VS proper and VS Code) is pretty fantastic. It’s generally very fast and the type information is generally very rich. TypeScript is open source (Apache License) and can accept changes from the community.
Microsoft has done a great job with creating TypeScript and they continue to rapidly improve the language. It’s a safe assumption that TypeScript is the most popular type system for JavaScript today (66,000+ downloads on npm in the middle of a given day, next to Flow’s 8,000+).
TypeScript is often cited as a way to ease C#/.NET developers into client-side code. This is believable — the language was heavily influenced by C#’s own architect. Tooling has also existed in various forms to create TypeScript classes from C# classes, so that type signatures can be shared across the stack, something I view as the “holy grail” of type systems. In my experience, aside from IDE integration especially in Microsoft shops, one of the popular reasons for choosing it is its “class-based” approach, which the community around it seems to follow.
TypeScript is itself written in TypeScript (Github cites 100% as of August 2017). Since that compiles to JavaScript, which runs on top of node.js, it is also cross-platform.
Please, stop saying it’s “Just JavaScript”
TypeScript is described as a “language”. This is absolutely true — it is not merely a type system or just a syntax for adding type decorations, like Flow Type.
Microsoft claims that TypeScript is “just JavaScript with optional static types.” and Wikipedia cites TypeScript as a “strict syntactical superset of JavaScript”. TypeScriptLang.org, TypeScript’s own site, omits the “strict” but still says it’s a “typed superset of JavaScript”. Is TypeScript a superset of JavaScript? And what does that mean, anyway?
This StackOverflow answer explains pretty well that, in the strictest sense, a programming language can only be a superset if every program in language A (in this case, JavaScript) is also valid in language B (the superset, in this case, TypeScript).
This is most certainly not true of TypeScript, and usually for good reason. Many programs that are “valid” in JavaScript are not “correct”. The opposite, in TypeScript, is also unfortunately true — many programs that are “correct” in JavaScript are not “valid” in TypeScript. The simplest example is that adding a property to an anonymous object in TypeScript is an error:
These differences mean that much idiomatic JavaScript, due to its dynamic nature, and most JavaScript code in the wild today is not valid TypeScript (ie: jQuery, Angular 1, React, etc.). I wish this was where the differences ended and we could just say I’m splitting hairs here, but it’s not.
TypeScript and Modules … I mean namespaces
It was October of 2012 when Microsoft took the wraps off of TypeScript 0.8.0, which included its own concept of “modules”. It would be another 2 years before the ECMAScript 2015 module syntax would be finalized.
Other popular module systems — CommonJS and AMD — had existed and proliferated since at least 2009, adopted or implemented by runtimes like node.js (CommonJS) or libraries like require.js (AMD).
What CommonJS, AMD and even ECMAScript 2015 all had in common is that they all provided a way to reference other files to load as dependencies and provide a private context or scope for your module code. Although AMD allowed creating multiple modules in the same file, a “file” could be considered a module, and loaded that way.
TypeScript introduced its own “module” keyword (whereas CommonJS only introduced a variable). You can declare modules in TypeScript, which have no relationship to the file that contains them. TypeScript also introduced its own “export” keyword, because variables defined within a TypeScript module are not publicly accessible by default, even at runtime.
When the ECMAScript 2015 module syntax became finalized, it introduced its own “import” and “export” keywords and this caused a great deal of confusion, prompting the TypeScript team to rename “modules” to “namespaces”, but to this day “module” and “export” still behaves the same way in TypeScript. In fact, if you write a file in TypeScript without using “import” or “export” at all within that file, the compiler will assume that this code is intended to execute in the global scope. This is in contrast to how the browser or node.js works and will work, since both have ways outside of the code to identify ES modules that TypeScript doesn’t know about.
TypeScript, Public and Private
TypeScript introduces “private” and “public” access modifiers on class methods and fields. Given the following code sample, which of these are publicly accessible at runtime?
If you guessed “all three of them”, you’d be correct. This means that the “private” keyword does not actually hide these properties from functions like JSON.stringify() or Object.keys() or using a for loop over an instance, ie: “for(x of y)”.
Meanwhile in JavaScript, the private fields proposal has already reached stage 3 in the ECMAScript standards process. These are true private fields and cannot be accessed publicly or by another class in an inheritance chain. This means it’s fine for two classes to have the same private field “#foo” but not “private foo”. I imagine that having 2 separate ways to declare private variables which behave differently both at runtime and design time will be confusing.
TypeScript and Arrow Functions
Consider this output from the Chrome Developer Tools Console:
If you try this example in Babel, you will get the same output, “1”. If you try this in TypeScript, you will get a compiler error: “The ‘arguments’ object cannot be referenced in an arrow function in ES3 and ES5. Consider using a standard function expression.” This is because the “arguments” reference in an arrow function is lexically bound, just like the “this” reference. In order to transpile this correctly to environments that do not have native arrow functions, Babel aliases the outer scope’s “arguments” reference, but TypeScript does not. Sure enough, TypeScript will transpile your code despite errors, but it will not behave the same way and incorrectly return “undefined”.
This is one of several cases where TypeScript doesn’t understand something and will change your code such that it will behave differently at runtime than even the browser itself. Assuming you can just ignore these errors and hope TypeScript will leave the code be is inviting peril.
TypeScript and Decorators
When ES5 came out, it introduced Object.defineProperty(). This allowed JavaScript developers to create true properties using descriptor objects without creating new syntax. Object.defineProperty() is another JavaScript feature TypeScript doesn’t understand and Decorators could be thought of as sort of an extension or evolution of this API, in that they can modify or create a new descriptor for the thing they decorate (configuring whether it is writable, readable, enumerable, etc.), but also receive a reference to the target itself. For JavaScript, being the dynamic language that it is, this means the decorator can also mutate that object (assuming it’s not frozen or sealed, which I’ll cover later), such as extending it with additional properties.
In 2014 the Google Angular team decided to create a new language, “AtScript”, to facilitate their use of what would evolve into decorators in the new version of their Angular framework. AtScript transpiled down to TypeScript, which then transpiled to JavaScript. The Microsoft TypeScript team shortly thereafter and with much fan-fair announced that they would adopt all of the features of AtScript, and co-announced with the Angular team that the new Angular would be written in TypeScript.
The Babel transpiler also adopted decorators in version 5, but the standard was unstable and subject to change to the point that they decided to exclude the feature from Babel 6.
Years later, the latest spec for decorators is still evolving, and much of it is still stage 0, but already any existing implementation of decorators will be incompatible with the spec as written today. Although TypeScript ships the feature with a warning that it’s experimental, much has already been built on top of the previous draft of the proposal. TypeScript did put decorators behind a warning and required a flag to make it go away, but even so, will the TypeScript team follow the specification and abandon this version, causing all of that code to break? What happens then to all that Angular code?
TypeScript and Iterables
One of the “shortcuts” the TypeScript transpiler takes is to use “.slice()” to transpile spreading arrays (as of this writing). If you’ve ever written a generator function in JavaScript, you’re probably aware that generators are iterable, which means that in Chrome today you can write code like this:
If you try this in TypeScript, as of this writing, you’ll get an “Type ‘IterableIterator<1 | 2>’ is not an array type.” compiler error. Once again, TypeScript will compile your code under protest, but instead of assigning the value to “x” as the developer console would, it’ll throw an error because it’ll be transpiled to “var x = foo().slice();”. Edit: I’ve been informed this can be fixed using “downlevelIteration:true” or “target:es2015” compiler options. These examples were produced using the TypeScript playground.
TypeScript’s “protected” keyword
JavaScript has plenty of its own warts. Features like the “with” block are so ill-advised, new developers may never encounter them in the wild today.
As I’ve mentioned before, TypeScript is said to ease C# developers into client code, and in my opinion, carries with it an OOP centric mindset. In TypeScript, a “protected” method or field is only accessible within that class, and also its descendants. There is no “protected” keyword in ECMAScript for similar reasons that there is no “private” keyword.
I was disappointed when I discovered this addition to the TypeScript language. For over a decade, JavaScript has been my sweet escape from server-side inheritance-chain hell, with few exceptions. The “protected” keyword violates the SOLID open-closed principle by design, and enables and encourages sharing private state between classes along their inheritance chain.
I’m sure you’ve heard it before — inheritance is (often) an anti-pattern and “prefer composition over inheritance”. Just imagine all of the abuses this keyword will bring.
TypeScript and ES5
ECMAScript 5 introduced several new features without adding new syntax. Among them were Object.defineProperty(), Object.freeze(), Object.seal() and Function.bind(), all of which work all the way back to Internet Explorer 9. TypeScript does not properly understand any of these. I will give TypeScript credit for understanding Object.assign() (but of course, it chokes on dynamic computed properties).
Object.defineProperty() provided properties with true “getter” and “setter” functionality in JavaScript long before ES6 came along and gave us class and extended object syntax to achieve the same functionality. It also allows us to define properties that aren’t enumerable, for example, they wont be found using Object.keys() or a for(x of y) loop over the object. TypeScript makes no attempt to understand how Object.defineProperty() may have changed your object.
Object.freeze() allows us a sort of “shallow immutability”. An object that is frozen cannot be changed — for example, you cannot add items to a frozen array or add a property to a frozen object, an exception will be thrown. TypeScript is ignorant of this and will not warn you if you try.
Object.seal() makes which properties the object has and their configuration immutable (as opposed to making their values immutable). Like a frozen object, you cannot add new properties to an object that is sealed, but unlike a frozen object, you can still change properties that already exist. Once again, TypeScript is in the dark about this (although in general adding a property to an object in TypeScript isn’t tolerated by the compiler) despite this throwing an exception at runtime if you attempt it.
Function.bind() takes any given function and returns a copy of it. This new copy will be bound to the “this” context you specify, and any arguments you specify will be pre-filled. TypeScript recognizes bound functions as returning and accepting “any”, in other words, it has no idea what they return or what they accept as parameters, allowing you to do all sorts of nonsensical things without upsetting the compiler.
TypeScript and Shims
Many moons ago, TypeScript’s score on the kangax compatibility table (at less than 40%) for ECMAScript 2015 paled in comparison to Babel’s (over 70%). One difference Babel had going for it, was that Babel’s score included the use of core.js. This makes sense, because, after all, Babel includes core.js and specifically references it in the documentation.
Well, I suppose the TypeScript team felt a bit left out. One day, the TypeScript compatibility score surged overnight to rival that of Babel’s, and it was easy to see why — it now included core.js as part of the score. By their own admission, the TypeScript team recognizes that they would only have scored about 30% on the compatibility table without this shim.
The only problem was, how was anyone supposed to use core.js with TypeScript? Despite updating their score, TypeScript’s site had no documentation on how to bring core.js into a TypeScript project and core.js had no association with TypeScript. TypeScript even ships with some of its own shims, apart from core.js. Even to this day, out of the box in a new Web Application in Visual Studio 2017, TypeScript assumes that methods such as “Array.includes” and “Object.assign” don’t exist, and there’s no obvious clue that you need to add type definitions and a shim for these built-in methods.
Once you TypeScript, you can’t go back
It’s bad enough that adding TypeScript to a project in Visual Studio will add XML elements that cause VS to check your project, and that I’ve seen the only way to remove them is by hand. This is minor compared to the fact that TypeScript is not a type annotation system — it is a compiler.
I’ll contrast this against Flow to illustrate.
In the left image, you have a class “MyClass” with some type definitions. In the right image you have the code after the Flow type information has been stripped. If you adopted Flow and later decide it’s not for you, there’s no tangible cost to removing Flow from your project. Let’s try this in TypeScript.
Now, the code on the right is not unreadable to a skilled JavaScript developer. It’s basically AMD with the revealing module pattern, but this is also a very simple example and TypeScript can generate some very “messy” code when you use advanced JavaScript features. One thing that’s inarguable — it’s not the same code that was written on the left.
TypeScript is “unsound” — but so what?
“Sound” can also be thought of as “provably correct”. A type system that is sound can be said to never permit an incorrect program to pass type checking. An “unsound” type system makes trade-offs for a weaker/less expressive type system with the goal of gaining benefits such as increased productivity.
What this does mean, however, is that there is a subset of surprising errors that TypeScript, even with all of the options turned on, cannot catch.
In the screenshot, you have a class Animal with two sub-classes, Dog and Cat. If you create an array of Cats, and then alias that array as an array of Animals, TypeScript will allow you to add Dogs to the array which lack a “purr” method, causing an error at runtime. Searching through the Github issues on the TypeScript repository reveals other cases where this sort of behavior works as intended, because being “sound” is an express non-goal of TypeScript.
If TypeScript is another language anyway, is it the best option?
Okay, I get it. JavaScript plays fast and loose with types and you want to build an important financial/medical/life-saving application that doesn’t think “0.1 + 0.2 !== 0.3” (too bad, type systems don’t help you there!), but it still has to run in the browser. Perhaps you’ve evaluated Flow and found that it just doesn’t meet your criteria. So, what do you do, since Silverlight is dead?
The fact you’d consider TypeScript likely means you work in a Microsoft shop anyway, so why not one of the many compile-to-JS languages like F# or even C# that have fantastic tooling or better type systems?
Even more interesting is the rapid evolution of WebAssembly, which has already shipped in all of the modern browsers. The mono team is working to make compiling C#-to-WASM a reality. This goes far beyond transpiling code and is real, true .NET running natively in the browser. I’m sure the 200mb web app is not far behind.
In Summary
If you live in Visual Studio, or even VSCode, and you compare the out-of-the-box JavaScript editing experience to that of TypeScript, TypeScript wins hands down. Would you expect anything different? The engine these IDEs use to analyze your code is the TypeScript engine. All three are Microsoft products. TypeScript is fast and performant and has a lot of momentum.
JavaScript is the standard, and although TypeScript intends to follow JavaScript, there are times when it deviates. A lot of people seem to think that TypeScript is “what JavaScript will become”, types and all, and I’m fairly confident this is proven to be incorrect (a subject for a whole different blog post). For that matter, the industry, as well as major players throughout history like Microsoft, Adobe, Google and Netscape have been trying to add types to JavaScript (the language itself) for two decades now, and have yet to succeed.
In contrast, I can adopt future JavaScript features that have been standardized, but just haven’t been implemented in all of the runtimes, and assume that eventually one day my code will run in all of them.
If I want a type annotation system, Flow is built to work with JavaScript as its written today. If I want more confidence in my code, a type annotation system is not the first place I go to. There are other tools that bring greater security (ie: unit tests and writing many small, maintainable ES6 modules) or work with the language (ie: ESLint, and of course Flow).