TypeScript vs. JavaScript: Your Go-to Guide
Considering you can include as much plain JavaScript as you like in your TypeScript project, what factors should you consider when choosing TypeScript for your application? Let’s explore.
Considering you can include as much plain JavaScript as you like in your TypeScript project, what factors should you consider when choosing TypeScript for your application? Let’s explore.
Daniele is a full-stack developer and cloud solution architect who has worked with many software environments, such as Java-based back ends, Angular- and React-based front ends, and serverless or hybrid cloud infrastructures.
Expertise
TypeScript or JavaScript? Developers contemplate this choice for greenfield web or Node.js projects, but it’s a question worth considering for existing projects too. A superset of JavaScript, TypeScript offers all of the features of JavaScript plus some additional perks. TypeScript intrinsically encourages us to code cleanly, making the code more scalable. However, projects can contain as much plain JavaScript as we like, so using TypeScript is not an all-or-nothing proposition.
The Relationship Between TypeScript and JavaScript
TypeScript adds an explicit type system to JavaScript, allowing for the strict enforcement of variable types. TypeScript runs its type checks while transpiling—a form of compiling that converts TypeScript code to the JavaScript code web browsers and Node.js understand.
TypeScript vs. JavaScript Examples
Let’s start with a valid JavaScript snippet:
let var1 = "Hello";
var1 = 10;
console.log(var1);
Here, var1
starts out as a string
, then becomes a number
.
Since JavaScript is only loosely typed, we can redefine var1
as a variable of any type—from a string to a function—at any time.
Executing this code outputs 10
.
Now, let’s change this code into TypeScript:
let var1: string = "Hello";
var1 = 10;
console.log(var1);
In this case, we declare var1
to be a string
. We then try to assign a number to it, which isn’t allowed by TypeScript’s strict type system. Transpiling results in an error:
TSError: ⨯ Unable to compile TypeScript:
src/snippet1.ts:2:1 - error TS2322: Type 'number' is not assignable to type 'string'.
2 var1 = 10;
If we were to instruct the transpiler to treat the original JavaScript snippet as if it were TypeScript, the transpiler would automatically infer that var1
should be a string | number
. This is a TypeScript union type, which allows us to assign var1
a string
or a number
at any time. Having resolved the type conflict, our TypeScript code would transpile successfully. Executing it would produce the same result as the JavaScript example.
TypeScript vs. JavaScript From 30,000 Feet: Scalability Challenges
JavaScript is ubiquitous, powering projects of all sizes, applied in ways that would have been unimaginable during its infancy in the 1990s. While JavaScript has matured, it falls short when it comes to scalability support. Accordingly, developers grapple with JavaScript applications that have grown in both magnitude and complexity.
Thankfully, TypeScript addresses many of the issues of scaling JavaScript projects. We’ll focus on the top three challenges: validation, refactoring, and documentation.
Validation
We rely on integrated development environments (IDEs) to help with tasks such as adding, modifying, and testing new code, but IDEs cannot validate pure JavaScript references. We mitigate this shortcoming by monitoring vigilantly as we code to stave off the possibility of typos in variables and function names.
The magnitude of the issue grows exponentially when the code originates from a third party, where broken references in seldom-executed code branches could easily go undetected.
In contrast, with TypeScript, we can focus our efforts on coding, confident that any errors will be identified at transpile time. To demonstrate this, let’s begin with some legacy JavaScript code:
const moment = require('moment');
const printCurrentTime = (format) => {
if (format === 'ISO'){
console.log("Current ISO TS:", moment().toISO());
} else {
console.log("Current TS: ", moment().format(format));
}
}
The .toISO()
call is a typo of the moment.js toISOString()
method but the code would work, provided the format
argument isn’t ISO
. The first time we try to pass ISO
to the function, it will raise this runtime error: TypeError: moment(...).toISO is not a function
.
Locating misspelled code may be difficult. The current codebase may not have a path to the broken line, in which case our broken .toISO()
reference wouldn’t be caught by testing.
If we port this code to TypeScript, the IDE would highlight the broken reference, prompting us to make corrections. If we do nothing and attempt to transpile, we would be blocked, and the transpiler would generate the following error:
TSError: ⨯ Unable to compile TypeScript:
src/catching-mistakes-at-compile-time.ts:5:49 - error TS2339: Property 'toISO' does not exist on type 'Moment'.
5 console.log("Current ISO TS:", moment().toISO());
Refactoring
While typos in third-party code references are not uncommon, there’s a different set of issues associated with typos in internal references, like this one:
const myPhoneFunction = (opts) => {
// ...
if (opts.phoneNumbr)
doStuff();
}
A sole developer can locate and fix all instances of phoneNumbr
to end with er
easily enough.
But the larger the team, the more this simple, common mistake is unreasonably costly. In the course of performing their work, colleagues would need to be aware of and propagate such typos. Alternatively, adding code to support both spellings would bloat the codebase unnecessarily.
With TypeScript, when we fix a typo, dependent code will no longer transpile, signaling colleagues to propagate the fix to their code.
Documentation
Accurate and relevant documentation is key to communication within and between teams of developers. JavaScript developers often use JSDoc to document expected method and property types.
TypeScript’s language features (e.g., abstract classes, interfaces, and type definitions) facilitate design-by-contract programming, leading to quality documentation. Moreover, having a formal definition of the methods and properties to which an object must adhere helps to identify breaking changes, create tests, perform code introspection, and implement architectural patterns.
For TypeScript, the go-to tool TypeDoc (based on the TSDoc proposal) automatically extracts type information (e.g., class, interface, method, and property) from our code. Thus, we effortlessly create documentation that is, by far, more comprehensive than that of JSDoc.
Advantages of TypeScript vs. JavaScript
Now, let’s explore how we can use TypeScript to address these scalability challenges.
Advanced Code/Refactoring Suggestions
Many IDEs can process information from the TypeScript type system, providing reference validation as we code. Even better, as we type, the IDE can deliver relevant, at-a-glance documentation (e.g., the arguments a function expects) for any reference and suggest contextually correct variable names.
In this TypeScript snippet, the IDE suggests an autocompletion of the names of the keys inside the function’s return value:
/**
* Simple function to parse a CSV containing people info.
* @param data A string containing a CSV with 3 fields: name, surname, age.
*/
const parsePeopleData = (data: string) => {
const people: {name: string, surname: string, age: number}[] = [];
const errors: string[] = [];
for (let row of data.split('\n')){
if (row.trim() === '') continue;
const tokens = row.split(',').map(i => i.trim()).filter(i => i != '');
if (tokens.length < 3){
errors.push(`Row "${row}" contains only ${tokens.length} tokens. 3 required`);
continue;
}
people.push({ name: tokens[0], surname: tokens[1], age: +tokens[2] })
}
return {people, errors};
};
const exampleData = `
Gordon,Freeman,27
G,Man,99
Alyx,Vance,24
Invalid Row,,
Again, Invalid
`;
const result = parsePeopleData(exampleData);
console.log("Parsed People:");
console.log(result.people.
map(p => `Name: ${p.name}\nSurname: ${p.surname}\nAge: ${p.age}`)
.join('\n\n')
);
if (result.errors.length > 0){
console.log("\nErrors:");
console.log(result.errors.join('\n'));
}
My IDE, Visual Studio Code, provided this suggestion (in the callout) when I started to call the function (line 31):
What’s more, the IDE’s autocomplete suggestions (in the callout) are contextually correct, showing only valid names within a nested key situation (line 34):
Such real-time suggestions lead to speedier coding. Moreover, IDEs can rely on TypeScript’s rigorous type information to refactor code on any scale. Operations such as renaming a property, changing file locations, or even extracting a superclass become trivial when we are 100% confident in the accuracy of our references.
Interface Support
In contrast with JavaScript, TypeScript offers the ability to define types using interfaces. An interface formally lists—but doesn’t implement—the methods and properties an object must include. This language construct is particularly helpful for collaboration with other developers.
The following example highlights how we can leverage TypeScript’s features to implement common OOP patterns neatly—in this case, strategy and chain of responsibility—thus improving the previous example:
export class PersonInfo {
constructor(
public name: string,
public surname: string,
public age: number
){}
}
export interface ParserStrategy{
/**
* Parse a line if able.
* @returns The parsed line or null if the format is not recognized.
*/
(line: string): PersonInfo | null;
}
export class PersonInfoParser{
public strategies: ParserStrategy[] = [];
parse(data: string){
const people: PersonInfo[] = [];
const errors: string[] = [];
for (let row of data.split('\n')){
if (row.trim() === '') continue;
let parsed;
for (let s of this.strategies){
parsed = s(row);
if (parsed) break;
}
if (!parsed){
errors.push(`Unable to find a strategy capable of parsing "${row}"`);
} else {
people.push(parsed);
}
}
return {people, errors};
}
}
const exampleData = `
Gordon,Freeman,27
G;Man;99
{"name":"Alyx", "surname":"Vance", "age":24}
Invalid Row,,
Again, Invalid
`;
const parser = new PersonInfoParser();
const createCSVStrategy = (fieldSeparator = ','): ParserStrategy => (line) => {
const tokens = line.split(fieldSeparator).map(i => i.trim()).filter(i => i != '');
if (tokens.length < 3) return null;
return new PersonInfo(tokens[0], tokens[1], +tokens[2]);
};
parser.strategies.push(
(line) => {
try {
const {name, surname, age} = JSON.parse(line);
return new PersonInfo(name, surname, age);
} catch(err){
return null;
}
},
createCSVStrategy(),
createCSVStrategy(';')
);
const result = parser.parse(exampleData);
console.log("Parsed People:");
console.log(result.people.
map(p => `Name: ${p.name}\nSurname: ${p.surname}\nAge: ${p.age}`)
.join('\n\n')
);
if (result.errors.length > 0){
console.log("\nErrors:");
console.log(result.errors.join('\n'));
}
ES6 Modules—Anywhere
As of this writing, not all front-end and back-end JavaScript runtimes support ES6 modules. With TypeScript, however, we can use ES6 module syntax:
import * as _ from 'lodash';
export const exampleFn = () => console.log(_.reverse(['a', 'b', 'c']));
The transpiled output will be compatible with our selected environment. For example, using the compiler option --module CommonJS
, we get:
"use strict";
exports.__esModule = true;
exports.exampleFn = void 0;
var _ = require("lodash");
var exampleFn = function () { return console.log(_.reverse(['a', 'b', 'c'])); };
exports.exampleFn = exampleFn;
Using --module UMD
instead, TypeScript outputs the more verbose UMD pattern:
(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") {
var v = factory(require, exports);
if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports", "lodash"], factory);
}
})(function (require, exports) {
"use strict";
exports.__esModule = true;
exports.exampleFn = void 0;
var _ = require("lodash");
var exampleFn = function () { return console.log(_.reverse(['a', 'b', 'c'])); };
exports.exampleFn = exampleFn;
});
ES6 Classes—Anywhere
Legacy environments often lack support for ES6 classes. A TypeScript transpile ensures compatibility by using target-specific constructs. Here’s a TypeScript source snippet:
export class TestClass {
hello = 'World';
}
JavaScript output depends on both the module and the target, which TypeScript lets us specify.
Here’s what --module CommonJS --target es3
yields:
"use strict";
exports.__esModule = true;
exports.TestClass = void 0;
var TestClass = /** @class */ (function () {
function TestClass() {
this.hello = 'World';
}
return TestClass;
}());
exports.TestClass = TestClass;
Using --module CommonJS --target es6
instead, we get the following transpiled result. The class
keyword is used to target ES6:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TestClass = void 0;
class TestClass {
constructor() {
this.hello = 'World';
}
}
exports.TestClass = TestClass;
Async/Await Functionality—Anywhere
Async/await makes asynchronous JavaScript code easier to understand and maintain. TypeScript offers this functionality to all runtimes, even to those that do not provide async/await natively.
Note that to run async/await on older runtimes like ES3 and ES5, you’ll need external support for Promise
-based output (e.g., via Bluebird or an ES2015 polyfill). The Promise
polyfill that ships with TypeScript integrates easily into the transpiled output—we just need to configure the lib
compiler option accordingly.
Support for Private Class Fields—Anywhere
Even for legacy targets, TypeScript supports private
fields in much the same way as do strongly typed languages (e.g., Java or C#). In contrast, many JavaScript runtimes support private
fields through the hash prefix syntax, which is a finished proposal of ES2022.
Disadvantages of TypeScript vs. JavaScript
Now that we’ve highlighted the principal benefits of implementing TypeScript, let’s explore scenarios where TypeScript may not be the right fit.
Transpilation: Potential for Workflow Incompatibility
Specific workflows or project requirements may be incompatible with TypeScript’s transpilation step: for instance, if we needed to use an external tool to change the code after deployment or if the output generated must be developer-friendly.
For example, I recently wrote an AWS Lambda function for a Node.js environment. TypeScript was a poor fit because requiring transpilation would prevent me, and other team members, from editing the function using the AWS online editor. This was a deal breaker for the project manager.
Type System Works Only Until Transpile Time
TypeScript’s JavaScript output doesn’t contain type information, so it won’t perform type checks and, therefore, type safety can break at runtime. For example, suppose a function is defined to always return an object. If null
is returned from its use within a .js
file, a runtime error will occur.
Type information-dependent features (e.g., private fields, interfaces, or generics) add value to any project but are scraped off while transpiling. For example, private
class members would no longer be private after transpilation. To be clear, runtime issues of this nature are not unique to TypeScript, and you can expect to encounter the same difficulties with JavaScript too.
Combining TypeScript and JavaScript
Despite TypeScript’s many benefits, sometimes we can’t justify converting an entire JavaScript project all at once. Fortunately, we can specify to the TypeScript transpiler—on a file-by-file basis—what to interpret as plain JavaScript. In fact, this hybrid approach can help to mitigate individual challenges as they arise over the course of a project’s life cycle.
We may prefer to leave JavaScript unchanged if the code:
- Was written by a former colleague and would require significant reverse-engineering efforts to convert to TypeScript.
- Uses technique(s) not allowed in TypeScript (e.g., adds a property after object instantiation) and would require refactoring to adhere to TypeScript rules.
- Belongs to another team that continues to use JavaScript.
In such cases, a declaration file (.d.ts
file, sometimes called the definition file or typings file) gives TypeScript enough type data to enable IDE suggestions while leaving JavaScript code as is.
Many JavaScript libraries (e.g., Lodash, Jest, and React) provide TypeScript typings files in separate type packages, while others (e.g., Moment.js, Axios, and Luxon) integrate typings files into the main package.
TypeScript vs. JavaScript: A Question of Streamlining and Scalability
The unrivaled support, flexibility, and enhancements that are available through TypeScript significantly improve the developer experience, enabling projects and teams to scale. The chief cost of incorporating TypeScript into a project is the addition of the transpilation build step. For most applications, transpiling to JavaScript is not an issue; rather, it’s a stepping stone to the many benefits of TypeScript.
Further Reading on the Toptal Blog:
Understanding the basics
Is TypeScript better than JavaScript?
TypeScript is better than JavaScript in terms of language features, reference validation, project scalability, collaboration within and between teams, developer experience, and code maintainability.
Why is TypeScript used instead of JavaScript?
JavaScript has evolved but continues to have codebase and development-team scaling issues that TypeScript can solve.
Is TypeScript front-end or back-end?
TypeScript code can be used for both front-end and back-end projects because it transpiles to JavaScript before running.
Is TypeScript's performance better than JavaScript's?
TypeScript code typically won’t perform any differently than JavaScript code, since it’s transpiled to JavaScript before running. But in the sense of developer performance: Yes, TypeScript makes it easier to write accurate code more quickly and catch bugs prior to runtime.
Albano Laziale, Metropolitan City of Rome, Italy
February 1, 2021
About the author
Daniele is a full-stack developer and cloud solution architect who has worked with many software environments, such as Java-based back ends, Angular- and React-based front ends, and serverless or hybrid cloud infrastructures.