The TL;DR answer is - yes, Typescript can really help.
For me, there are two main characteristics of good code:
- It works (with little to no bugs)
- The code is readable and extendable
As a developer, my main task is to implement a program that works perfectly and meets the user’s expectations. That's why we write tests and hire QA engineers to be sure that everything works as intended.
At some point we need to improve or re-implement some functionality which will require a lot of effort as the application’s complexity grows every day. That’s why we should keep code readability as high as it is possible, because it is essential to simplify such changes and decrease their cost.
Javascript is a friendly and powerful language but sometimes its flexibility and simplicity doesn’t help those who use it. In this article, I’ll explain my view on how writing JS code using the Typescript superset increases its quality.
Static types help avoid accidental mistakes
There are 3 types of errors that can occur in code:
- Logical errors
- Syntax errors
- Semantic errors
Typescript can’t do anything with logical errors, but it checks syntax and semantic errors before code goes to production. It builds a type map and checks whether they are matched with actions you are trying to make with them and throws an error when a mismatch occurs. Also it checks the syntax to prevent small mistakes when you, for instance, forget to end a line with ‘;’ or set the wrong order of “?” and “:” symbols in the ternary operator.
One of the tangible and universally recognized advantages of JS development is the speed of development one can achieve. One of the factors is that there are no ahead-of-time compilation steps and hence no waiting - you hit the “save” button and immediately view the results of the changes. But the toll for this speed is semantic errors due to the lack of compile-time checks. You will often be able to find errors immediately and fix them (which of course requires some time), but the scary part is when such errors appear unexpectedly. When you implement a feature, you have a limited amount of mock test cases and at the most liable moment you may expect a number but receive a string from the server and get NaN as a result of the calculation. Just defining the following basic data types helps you avoid such errors:
- boolean
- number
- object
- string
- array
- void
- null & undefined
Using such an approach is similar to giving a promise to the rest of the team that if you declare a variable as a number, for instance, - this will only be a number and nothing else.
Furthermore, the argument about the inconvenience of an additional step in the build pipeline fades away as all modern frameworks require transpiling ES2016 to ES5 to achieve full browser support and we end up having one build tool or another processing our source files - be it gulp or webpack.
Advanced types elevate readability
Typescript also provides the following advanced types:
- Interfaces
- Generics
- Union types
We all are the end-users of complex products. Take a car as an example. It is really hard to figure out how everything works in detail and I am sure that it is redundant. It is only necessary to have a high-level knowledge of how the device works. So, you know what data you’ll get as an input and implement the necessary functionality to return an output, which other developers expect to get as an input for his module.
The same is true for programming. One of the most important reasons why JS is so popular nowadays is that it has a huge and vibrant community. There is a copious amount of open source libraries which increase development speed several times over and help you avoid reinventing the wheel. And just as it is for cars, I just want to see the interface to quickly figure out what data is required for input and what result I can achieve. Of course, you can read the documentation but it requires additional time and effort on your part.
I like to read code as a book without tons of documentation. For instance, take a look at the following snippet of code from the “Grommet” library that describes options for select control:
export interface ISelectProps {
defaultValue?: ISelectOption | string;
/**
* What text to start with in the input.
*/
id?: string;
/**
* The id attribute of the input.
*/
/* -------------------------------------------------------------- */
inline?: boolean;
/* -------------------------------------------------------------- */
name?: string;
/**
* The name attribute of the input.
*/
onChange?: ({target, option, value}) => void;
/**
* Function that will be called when the user selects a option. The target corresponds to the embedded input element, allowing you to distinguish which component triggered the event. The option contains the object chosen from the supplied options.
*/
onSearch?: (event) => void;
/**
* Function that will be called when the user types in the search input. If this property is not provided, no search field will be rendered.
*/
options?: Array<ISelectOption | string>;
/**
* Options can be either a string or an object. The label property of option objects can be a string or a React element. This allows rendering richer option representations.
*/
placeHolder?: string;
/**
* Placeholder text to use when the search input is empty.
*/
value?: ISelectOption | string;
/**
* What text to put in the input.
*/
style?: React.HTMLProps<HTMLStyleElement>;
className?: string;
propTypes?: any;
}
From my point of view, writing documentation is 100% necessary for libraries, but it should be separated from the code itself (except for very special exceptions). In this example, all these comments just prevent me from finding what I really need. I find the following version of this example much more informative:
export interface ISelectProps {
defaultValue?: ISelectOption | string;
id?: string;
inline?: boolean;
name?: string;
onChange?: ({target, option, value}) => void;
onSearch?: (event) => void;
options?: Array<ISelectOption | string>;
placeHolder?: string;
value?: ISelectOption | string;
style?: React.HTMLProps<HTMLStyleElement>;
className?: string;
propTypes?: any;
}
I can see everything I need within this concise snippet:
- Intuitive, understandable properties and functions names
- Their types and parameters
But if we take a look at a pure JS implementation of such an interface it won’t be as informative as the Typescript version:
const SelectProps = {
defaultValue: '',
id: '',
inline: true,
name: 'default name',
onChange: () => {},
onSearch: () => {},
options: [],
placeholder: '',
value: 'default',
style: {},
className: '',
propTypes: {}
}
Typescript helps increase the speed of writing code
Microsoft provides a great tool for navigation inside a project and shows its details in any part of the application. This tool is called IntelliSense. It exempts you from searching throughout a whole application for details like the type of a parameter, properties of an object and even values I can put inside an array. Just by hovering over a method’s name you can get its signature that in most of the cases is enough to understand how to work with it. And if you need more details, you can go to its implementation just by pressing a hotkey.
Of course, you can achieve similar functionality using a bunch of plugins for popular text editors, like Sublime or Visual Code, but it won’t be as good as what Typescript provides. The reason for this is that Typescript defines static types and it builds a metadata tree with maximum precision. In pure JS, it is necessary to rebuild this tree over and over when you type a new line of code because JS has dynamic typization and there is a chance that a property type can be changed. In the case of an object, it's hard to even detect the amount of properties.
With the help of generics you can get all the benefits of IntelliSense, even using methods which can receive parameters of different types, because Typescript will set an exact output type according to the types of input.
You can use any amount of Typescript
There are a few scenarios when you may not want to use static types. For instance, most libraries today are implemented using pure Javascript and while you use it, you may want to change its logic a bit, but the rest of the methods should stay the same.
I’ve got this situation with the Grommet Select component. The library didn’t work the way I expected it to, and to solve the issue it was necessary to add just 3 lines of code to this component. Here, I could extend the existing component and then override only one method. Of course, in this case, I didn’t want to spend a lot of time writing all the properties types for the component.
It could be easily implemented thanks to the “any” type in Typescript. When Typescript parses the code and builds its tree, in cases where the compiler can’t figure out the type immediately, it just sets it it as “any.” In this case, type validation won’t be strict for such variables.
There are other tools which would make several passes through the tree to set the exact type for each variable. If you need such strictness, you can take a look at the “Flow.”Typescript has plenty of syntactic sugar
Typescript is a superset of Javascript. This means that it is not a brand new language, but it extends an existing one. When you run the Typescript compiler - it transpiles TS code to JS. There are a few features that just make your code more readable and force you to follow conventions, but after compilation, these rules could be broken in pure JS.
One such feature is encapsulation. Typescript supports access modifiers and will notify you if you try, for instance, to use private variables outside their class. However, if you run transpiled code - you could get access to them.
Another feature is methods overloading. We can define a few signatures and build one method with separate processing for different inputs. There is one disadvantage in Typescript with such an approach compared to conventional static-typed languages. In C#, for example, you can specify several methods with the same name but different input parameters and C# will resolve the method to be called at runtime. In TS, you should check variables manually and call the necessary part of the code (for instance, you can implement it with a switch statement).
/*
SIGNATURE
*/
export function setInnerObjProp < T,
K1 extends keyof T > (obj : T, keys : [
K1
], value : T[K1]);
export function setInnerObjProp < T,
K1 extends keyof T,
K2 extends keyof T[K1] > (obj : T, keys : [
K1, K2
], value : T[K1][K2]);
export function setInnerObjProp < T,
K1 extends keyof T,
K2 extends keyof T[K1],
K3 extends keyof T[K1][K2] > (obj : T, keys : [
K1, K2, K3
], value : T[K1][K2][K3]);
export function setInnerObjProp<T,
K1 extends keyof T,
K2 extends keyof T[K1]>(obj: T, keys: [K1, K2], value: T[K1][K2]);
export function setInnerObjProp<T,
K1 extends keyof T,
K2 extends keyof T[K1],
K3 extends keyof T[K1][K2]>(obj: T, keys: [K1, K2, K3], value: T[K1][K2][K3]);
/*
IMPLEMENTATION
*/
export function setInnerObjProp(obj: object, keys: string[], value: any) {
const newObj = Object.assign({}, obj);
let curObj: any = newObj;
keys.slice(0, keys.length - 1).forEach(key => {
curObj = curObj[key] = Object.assign({}, curObj[key]);
});
const lastKey = keys[keys.length - 1];
curObj[lastKey] = value;
return newObj;
}
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function setInnerObjProp(obj, keys, value) {
const newObj = Object.assign({}, obj);
let curObj = newObj;
keys.slice(0, keys.length - 1).forEach(key => {
curObj = curObj[key] = Object.assign({}, curObj[key]);
});
const lastKey = keys[keys.length - 1];
curObj[lastKey] = value;
return newObj;
}
exports.setInnerObjProp = setInnerObjProp;
var DayOfWeek;
(function (DayOfWeek) {
DayOfWeek[DayOfWeek["Sunday"] = 0] = "Sunday";
DayOfWeek[DayOfWeek["Monday"] = 1] = "Monday";
DayOfWeek[DayOfWeek["Tuesday"] = 2] = "Tuesday";
DayOfWeek[DayOfWeek["Wednesday"] = 3] = "Wednesday";
DayOfWeek[DayOfWeek["Thursday"] = 4] = "Thursday";
DayOfWeek[DayOfWeek["Friday"] = 5] = "Friday";
DayOfWeek[DayOfWeek["Saturday"] = 6] = "Saturday";
})(DayOfWeek || (DayOfWeek = {}));
const today = DayOfWeek.Saturday;
//# sourceMappingURL=interfaces.js.map
One more sweet feature is enums. Enums are useful when you have some codified entity inside an app, for instance, the day of the week. In this case, you can use an enum to write what this code means instead of an integer value, you shouldn’t remember all these codes by heart. Also, you can get a string value of the used enum item.
Conclusion
When creating complex applications, we rely on lots of conventions in a number of libraries, frameworks and even arrangements inside a team but there are no mechanisms to check all of them all the time.
Typescript is a powerful JS superset that helps you write more readable and more reliable code. It provides you an ability to set rules of different levels of strictness for your team to achieve following conventions and increase code reliability. Typescript is a mechanism to easily enforce these conventions in your team in any amount you want.