Easing Into Node.js With TypeScript

September 14, 2018 - by Colin J. Ihrig

As Node.js continues to increase in popularity, the platform is seeing an influx of developers from other languages such as Java and C#. Typically, the two largest hurdles these would-be JavaScript developers face are the asynchronous programming paradigm, and the lack of static typing. Newer incarnations of Node.js contain async/await, which greatly simplifies working with asynchronous code, but static typing is still a pain point for some developers migrating from compiled languages. The fact that a JavaScript variable can hold any data type at any point during execution is an extremely common source of bugs in large applications. That is where TypeScript comes into the picture.

The remainder of this blog post introduces the TypeScript language, including some of its advantages and disadvantages compared to vanilla JavaScript. This post is not intended to be a comprehensive language reference, but will point interested readers in the right direction to learn more. The code samples throughout this post assume Node 8.9.0 or greater.

Introduction

TypeScript markets itself as "JavaScript that scales." In reality, TypeScript is yet another compile-to-JavaScript language in the same vein as CoffeeScript, ClojureScript, and numerous others. However, TypeScript's syntax is a strict superset of JavaScript, meaning that it can be integrated into existing projects immediately.

To illustrate the simplicity of getting started with TypeScript, create a file named hello.js containing the following code:

'use strict';

function greet(name) {
  console.log(`Hello ${name}!`);
}

greet('Peter Pluck');

Next, rename the JavaScript file to have a .ts extension, and run the TypeScript compiler, tsc:

$ mv hello.js hello.ts
$ npx tsc hello.ts

These commands will create a new hello.js file containing the compiler output shown below. In this example, the input was standard JavaScript, so the output looks nearly identical. However, note that the template literal has been replaced by string concatenation. This is because tsc defaults to outputting ECMAScript version 3 compatible code.

'use strict';
function greet(name) {
    console.log("Hello " + name + "!");
}
greet('Peter Pluck');

The target ECMAScript version can be configured using the --target command line flag as shown below. After running the following command, hello.js will contain a template literal.

$ npx tsc --target es6 hello.ts

Configuring the TypeScript Compiler

The TypeScript compiler is highly configurable, and will likely be run many times on dozens of files over a project's lifetime. For this reason, it is common to store the compiler configuration in a tsconfig.json file in the project root. In addition to compiler flags, TypeScript's configuration file can be used to include/exclude specific files, inherit from other configuration files, and more.

A simple example tsconfig.json file is shown below. The "include" and "exclude" fields tell tsc to compile all .ts files in the current directory tree, with the exception of the node_modules. The "declaration" and "sourceMap" compiler options tell tsc to output a type declaration file (more on this later) and a source map file. The "strict" option enables all of tsc's strict type checks and outputs code in strict mode. The "target" option instructs tsc to write ECMAScript 6 compatible code, while "outDir" specifies a directory to write all of the output files. Finally, "noEmitOnError" prevents tsc from outputting any code if errors are encountered. This is useful because, by default, tsc will write its output files even if compilation errors are encountered.

{
  "compilerOptions": {
    "declaration": true,
    "noEmitOnError": true,
    "outDir": "build",
    "sourceMap": true,
    "strict": true,
    "target": "ES6"
  },
  "include": ["**/*.ts"],
  "exclude": ["node_modules"]
}

The command npx tsc will automatically load tsconfig.json and execute accordingly. To avoid potential confusion, it is recommended to explicitly add the project directory using the -p flag. For example, npx tsc -p . will accomplish the same thing, but is more explicit.

TypeScript Syntax Basics

The examples up to this point have primarily focused on setting up and running the TypeScript compiler. Now that the compiler is running, we can explore some of TypeScript's language features.

The original hello.ts has been rewritten below with three small changes. First, 'use strict'; has been dropped. Instead of including this in every file, the TypeScript compiler can enforce it for us. The second change is the addition of : string next to the name function argument. This indicates that name must be of type string. Note that JavaScript primitive types should be lowercase. In other words, use string and number, not String and Number, as the latter refer to boxed primitive types. The third change is the addition of : void in the function signature. This indicates the function's return type.

function greet(name: string): void {
  console.log(`Hello ${name}!`);
}

greet('Peter Pluck');

Compile this code with tsc -p . and observe that the generated output is the same as before. Next, update the source code as shown below.

function greet(name: string): void {
  console.log(`Hello ${name}!`);
  return true;
}

greet(['Peter Pluck']);

Note that the parameter passed to greet() is now an array of strings instead of a string primitive, and greet() is returning a Boolean value. Next, attempt to compile the code again. The TypeScript compiler will report these deviations from the expected behavior:

hello.ts:3:3 - error TS2322: Type 'true' is not assignable to type 'void'.

3   return true;
    ~~~~~~~~~~~~

hello.ts:6:7 - error TS2345: Argument of type 'string[]' is not assignable to parameter of type 'string'.

6 greet(['Peter Pluck']);

The previous example illustrated basic TypeScript syntax for function arguments and return values. Next, we'll take a high level look at several other syntactic constructs provided by TypeScript, including variable declarations, enums, and interfaces.

The following example illustrates how types are associated with variables at declaration. This syntax is very similar to that of function arguments. It's worth pointing out the any data type used by anythingGoes. This data type is used when the data type is not known, or can change. Alternatively, if a variable can hold multiple data types, and the types are known a priori, then a union data type can be used as illustrated by the someThingsGo variable.

let done: boolean = false;
const PI: number = 3.14159;
const firstName: string = 'Peter';
const list: Array<string> = ['milk', 'eggs', 'bread'];
const anythingGoes: Array<any> = [false, undefined, null, 'foo'];
const someThingsGo: Array<string|number> = ['foo', 5];

It is also possible to assert a variable's type to the compiler, as shown below. In this case, firstName is of type any, but we want to assert that it is a string so that it's length can be stored in a number.

const firstName: any = 'Peter';
const nameLength: number = (firstName as string).length;

Enums are another common language feature missing from JavaScript. TypeScript supports enums using the following syntax. Note that initializers are optional, and each subsequent value in the enum auto-increments by one.

enum Color {
  Red = 1,
  Blue,
  Green
}

const favoriteColor: Color = Color.Red;

TypeScript also provides interfaces, a powerful mechanism for enforcing the shape of objects. In the following example, a Person interface is created. Each person must have a first name, last name, and age. A person may optionally have a middle name, as indicated by the ? following middleName. Furthermore, the person's age cannot be changed by the surrounding code because it is marked as readonly.

interface Person {
  firstName: string;
  middleName?: string;
  lastName: string;
  readonly age: number;
}

function printPerson(person: Person): void {
  const message: string = [
    person.firstName,
    person.lastName,
    'is',
    person.age,
    'years old'
  ].join(' ');

  console.log(message);
}

printPerson({ firstName: 'Peter', lastName: 'Pluck', age: 40 });

In the previous example, if the object passed to the printPerson() function does not adhere to the Person interface, then a compiler error is generated. Note that it is possible to work around this restriction in practice by using a type assertion, as shown below. However, deviating from the Person interface will cause printPerson() to log missing values.

printPerson({ firstName: 'Peter' } as Person);

TypeScript includes many more language features in addition to the ones mentioned here. Interested readers are encouraged to read through the Handbook section of the official documentation.

TypeScript Declaration Files

TypeScript declaration files specify the types used by a JavaScript codebase. These files, which carry a .d.ts file extension, can be generated automatically by the TypeScript compiler using the --declaration flag. In addition to specifying types, these files are also used by some editors, such as Visual Studio Code, to provide code completions.

It is also possible to use declaration files with JavaScript projects that don't utilize TypeScript, making it simple to begin integrating TypeScript and JavaScript code. The dts-gen utility can output a declaration file for an arbitrary JavaScript module, provided that the module is installed locally. For example, to create a declaration file for the zipit module, using the following commands:

$ npm i zipit
$ npx dts-gen -m zipit

The generated declaration file, zipit.d.ts, is shown below. The biggest drawback to this approach is that typically dts-gen can infer minimal typing information, leading to the catch all any data type being used. However, the module's interface is correctly defined, and this file can act as a good starting point for adding additional typing information by hand.

/** Declaration file generated by dts-gen */

export = zipit;

declare function zipit(options: any, callback: any): any;

declare namespace zipit {
    const prototype: {
    };

}

Publishing Declaration Files

In order for a declaration file to be used by others, it must be published to npm in one of two ways. The first, and preferred method, is to bundle the declaration file alongside the module source code during npm publish. The module author must also include a "types" or "typings" field in the module's package.json file. In the previous zipit example, assuming that the declaration file is stored as lib/index.d.ts, the package.json file would include the data shown below.

{
  "name": "zipit",
  "version": "1.0.2",
  "description": "Easily create zip archives",
  "main": "lib/index.js",
  "types": "lib/index.d.ts"
}

In some cases, it is not feasible to include a declaration file in an npm module. Examples of this include unmaintained modules, or modules whose authors do not wish to support TypeScript. In these cases, third party type declarations can be published via DefinitelyTyped, a self-proclaimed "repository for high quality TypeScript type definitions." To publish a declaration file here, open a pull request against the DefinitelyTyped/DefinitelyTyped GitHub repository. The published type definitions will be available on npm as @types/module-name. For example, types for the hapi module are available at @types/hapi.

Consuming Declaration Files

It is common for Node.js applications to take on many dependencies from npm. In order for TypeScript to work with these dependencies, it must know about any declaration files. If a module has been published with its declaration file, users do not need to take any additional action. However, if the declaration file has been published to DefinitelyTyped, then the corresponding @types module must also be explicitly installed in order for the TypeScript tooling to find it.

For more information on declaration files, see the comprehensive documentation on the TypeScript website.

TypeScript's Drawbacks

TypeScript addresses some very real issues with JavaScript, but it is not a silver bullet. The two biggest issues with TypeScript can be generalized as compilation and debugging:

  • compilation: By introducing TypeScript into a project, you're introducing a dependency on it's compiler and surrounding ecosystem. This means that any bugs or security issues with TypeScript can propagate to your application. It can also mean introducing additional complexity if your project was not already using a build step. For example, the hapi.js ecosystem does not allow build steps in its modules. Incorporating TypeScript into such an ecosystem would add non-trivial complexity. On the other hand, most front end projects already have significant tooling requirements that would be relatively unaffected by the introduction of TypeScript.

  • debugging: Developing in TypeScript and running JavaScript in production, necessitates source maps for debugging. Again, most front end projects will already require this, but it may surprise some back end developers. Users of postmortem debugging tools will be impacted to a larger degree, as source maps are generally not supported by this family of tools.

These drawbacks may or may not be a big deal depending on your team's experience and current workflow. However, they are definitely worth pointing out if your team is considering introducing TypeScript. No matter what, a strong grasp of the underlying JavaScript language is highly recommended, as that is what you'll ultimately be debugging in production.

Conclusion

This post has introduced the TypeScript language, it's compiler, and surrounding ecosystem. This post also attempted to weigh the pros and cons of adding TypeScript to your Node.js workflow. It is worth reiterating that this article has only scratched the surface of the TypeScript ecosystem. For example, TypeScript integrates well with Babel, webpack, and other tools, supports React's JSX, and has its own powerful set of tools like TSLint.

If you have any questions, feel free to reach out. Joyent offers comprehensive Node.js support, including TypeScript, and would love to work with you.