At Bright IT, we primarily use Scala for backend development. Scala is known, among other things, for its strong and expressive type system, which stands in sharp contrast to what Javascript is known for. As many of our developers write code for both frontend and backend, we'd prefer using a language closer to Scala to make transitions between the environments as easy as possible. Both Flow and Typescript are good options here - their goal is to add static typing to Javascript while remaining as close to it as possible.
This is important for two reasons - we do not want to abandon Javascript's enormous ecosystem of libraries and we want the transition to be as easy for our frontend-focused developers as possible. After a bit of research, we've decided to give TypeScript a try and we would like to share our experience using it.
One important thing to note is that all the examples in this article assume --strict
flag, which turns on all stricter type checking behaviors.
The Good
TypeScript is Not a Separate Language
For the most part, writing TypeScript feels like writing JavaScript with types. Most patterns that work in JavaScript work and can be typed in TypeScript. This means that developers used to JavaScript can be converted to TypeScript without much trouble and can become productive right away. We'd like to highlight three reasons why this is the case. The first, and the most mundane one for Scala developers, is type inference. In TypeScript, it's possible to skip a type annotation, in which case the type checker will automatically deduce the type based on what is assigned to the variable. This alone means that obvious type annotations, such as let str: string = ''
, can be omitted. Despite this being a seemingly small thing, needing type annotations in every single place is not something many people are fond of, and with good reasons:
Second important TypeScript's feature is structural typing. Structural typing means that types with the same members are interchangeable - if a function needs object with fields name: string
and age: number
, it doesn't matter if the object is called Employee
or Person
:
type Employee = {
name: string,
age: number,
}
type Person = {
name: string,
age: number,
}
let employee: Employee = { name: "Janusz", age: 30 };
let person: Person = employee;
This approach, which is sometimes called "static duck typing", matches how JavaScript programmers think about objects, which makes transitioning that much easier. Similarly, it's common practice in JavaScript to have special-case code at the beginning of functions if some argument is null
. To support this, TypeScript has flow-sensitive typing - variable types can be adjusted based on conditions in if
sand
while`s:
function greetLoudly(name?: string) {
// does not type check, since `name` is optional and potentially undefined:
// name.toUpperCase()
if (name == null) name = "world";
// but here `name` is guaranteed to be a string, so this does type check:
alert(`HELLO, ${name.toUpperCase()}!`);
}
To sum up: TypeScript authors have taken care to ensure that most common JavaScript idioms can be easily translated into TypeScript and the results of that effort are clearly visible.
The Type System Is Remarkably Powerful
Despite simple code examples being simple to write, TypeScript's type system is very expressive, even when compared to Scala. It supports multiple features not (yet) present in Scala, such as literal types, union types and polymorphic functions. Describing these features is a topic on its own, so we'd like to highlight one library as an example of what they allow: io-ts
. io-ts
helps with verifying at runtime that values conform to some type by defining validators for types. As the name suggests, its typical usecase is validating results of IO, such as HTTP requests. Here's how it works:
import * as t from 'io-ts';
// first define a "runtime type", as io-ts calls itconst PersonT = t.interface({
name: t.string,
age: t.number,
});
// the above can be used to validate if an object is of the following type:
// type Person = {
// name: string,
// age: number,
// }
// io-ts lets you keep your code DRY by equivalently writing:
type Person = t.TypeOf<type PersonT>
// these two values could be obtained by performing API requests
let untypedPerson = JSON.parse('{ name: "Janusz", age: 21 }');
let untypedNotPerson = JSON.parse('{ name: "Timer", duration: 21 }');
function castToPerson(input: object): Person {
return PersonT.decode(input).getOrElseL(() => throw 'decode err!');
}
castToPerson(untypedPerson); // succeeds and returns Person
castToPerson(untypedNotPerson); // throws an exception
It's interesting to note that Scala libraries typically take the reverse approach: the validators are automatically generated with a macro from the type to validate. io-ts
instead leverages the type system to calculate the type based on the validator.
The Bad
TypeScript Is Not a Separate Language
Again, it's a design goal of TypeScript to be a typed JavaScript. This means that some of its features are not as coherent as they'd be if all parts of the language were created with static typing in mind. As an example: unlike Scala trait
s, TypeScript interface
s do not support default method implementations, something which any Scala programmer will very quickly find out. In theory, already existing features can be leveraged to create mixins, which do allow default method implementation. However, the approach presented in TypeScript documentation (http://www.typescriptlang.org/docs/handbook/mixins.html) has multiple problems, such as type checking if an implementation is missing and requiring redeclaration of methods with default implementations. In addition, it's simply incompatible with --strict
.
It's possible to adjust the examples from the documentation to work with --strict
, as presented in a blog post here (https://blog.mariusschulz.com/2017/05/26/typescript-2-2-mixin-classes), but we discovered that this approach, in turn, prevented type declarations from working correctly (in particular: it hid fields added to Vue
as interface extensions). A detailed comparison between both approaches and Scala trait
s would be a topic for a separate post, but it suffices to say that mixins are a second-class TypeScript feature, at best.
Untyped Code Requires Good Discipline
TypeScript supports a "dynamic" type called any
, which essentially turns off type checking:
function anyTest(any: any) {
// it supports all operators
let num: number = any / 2;
let str: string = "stringified:" + any;
// and all methods
any.someRandomMethod(int, str);
any.anotherRandomMethod();
// everything can be assigned to any
let other: any = 5;
// and it can be assigned anywhere
let casted: string = any;
}
Its intended purpose is to help when migrating code from JavaScript or to allow using an untyped JavaScript library. There's a small problem with it, however. Variables of type any
can be assigned to variables of all other types, and no runtime checks will be added when this happens. In practice, this leads to situations like the following:
class LoudGreeter {
name: string = "world";
loudName() {
return this.name.toUpperCase();
}
greet() {
alert(`HELLO, ${this.loudName()}`!);
}
}
let greeter = new LoudGreeter();
let danger: any = 5;
greeter.name = danger; // this is the line where the actual error is made
greeter.greet() // Type Error: this.name.toUpperCase() is not a function
Now, what happens if a developer notices this kind of error? They have no choice but to manually search every single place in the codebase where a value is assigned to name
and manually check if it could have been infected with any
. It's perfectly possible that the place where an invalid value is assigned could be five steps removed from actually assigning anything to .name
. This is not something that a Scala programmer would expect from a type system. In particular, in Scala we could write similar code using manual type casts (.asInstanceOf
), but JVM throws an exception immediately if we try to cast Integer
to String
.
The issue becomes more problematic with every additional source of any
, like an untyped library. In our case, it was particularly painful as used TypeScript with Vue. Both Vue templates and Vuex (Vue Store) are completely untyped, which meant that more-or-less half the codebase was any
-typed. As you might imagine, this was a constant source of any
errors like the one above. In principle, any
is not a bad idea, but it needs to be carefully monitored.
The Ugly
TypeScript is type-unsafe as a design goal. Scary wording aside, what does this actually mean? To shortly explain a nuanced matter, a language is type-safe when a variable of some type can only contain values of that type during program execution. Explicit "type holes" such as any
or .asInstanceOf
are not considered for type-safety, because circumventing the type system is their explicit purpose. However, in TypeScript, simple arrays are unsafe:
class Animal { }
class Cat extends Animal { meow() { alert('meow!'); } }
class Dog extends Animal { woof() { alert('woof!'); } }
let cats: Cat[] = [new Cat];
let animals: Animal[] = cats;
// would not type check:
// cats[0] = new Dog;
// but this type checks fine:
animals[0] = new Dog;
let perhapsCat: Cat = cats[0];
perhapsCat.meow() // Type Error
Using only arrays, we've managed to assign a Dog
to a Cat
. In the process, we've assigned Cat[]
to Animal[]
- the technical name for this behavior is covariance. Covariant arrays are a well-known issue and Scala avoids it:
class Animal
class Cat extends Animal
class Dog extends Animal
val cats: Array[Cat] = Array(new Cat)
val animals: Array[Animal] = cats // fails to compile
You might be wondering - why didn't we define the meow
/woof
methods in Scala? Well, the reason is simple - if we didn't define them in TypeScript, Cat
and Dog
would be type unsafe by themselves:
class Animal
class Cat extends Animal
class Dog extends Animal
let dog: Dog = new Cat; // type checks
Wait, what? Here's what happens: in TypeScript, all types are structural. Since neither Cat
nor Dog
have any members, they are compatible with one another. This is despite the fact that we can tell the difference:
function describe(cat: Cat) {
if (cat instanceof Cat) {
return 'cat';
} else {
return 'not a cat';
}
}
alert(inspect(new Cat)) // cat
alert(inspect(new Dog)) // not a cat (!)
The Verdict
So: is TypeScript worth it? The answer is: well, it depends. It's important to understand that TypeScript's explicit design goal is to statically verify as much already existing JavaScript as possible and to strike a balance between type safety and productivity; however, the language is obviously biased towards the latter. If your primary goal is to write frontend code with as much type safety as possible, you might want to look somewhere else. If you want to remain close to JavaScript, Flow might be just what you need.
Despite that, we'd describe our overall experience with TypeScript positively. TypeScript was easy to introduce to JavaScript developers. For the most part, we had no trouble finding typed libraries. It did help us find errors even during migration from plain JavaScript, and we're happy with the expressivity of the type system. We cannot honestly say that our experience using TypeScript with Vue was positive, but we're much more satisfied with the combination of TypeScript and React.
To conclude: if you're primarily looking for a way to make your frontend code safer without too much overhaul, we encourage you to give TypeScript a try!