Typescript From a Scala Programmer's Perspective

Reading time 12 min

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 ifsandwhile`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-tsio-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 traits, TypeScript interfaces 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 traits 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!

About Bright

We are a team of marketing and technology experts who team up with you to create amazing digital services and products – websites, web applications and online shops on the pulse of time.