Trying TypeScript

Setting up and starting with TypeScript and Visual Studio Code editor.

I have used C++, C# and Java for a long time, and love the short, uncluttered view of JavaScript code. Yet, I find myself struggling to refactor my own code without breaking things. It is time to introduce a little bit of help to my source. I tried Flow using source comments, yet could not see many obvious benefits. Recently I have tried TypeScript (v2.2) with Visual Studio Code editor (v1.10) and must admit that it was a very positive experience. Here is a short tutorial to get anyone started with TypeScript.

Visual Studio Code editor

I like Sublime text editor so much! It is fast and has thousands of plugins. When I tried Visual Studio Code (VSCode) it came with built-in IntelliSense, Git integration (showing me changed and added code lines and good color themes. It felt like using Sublime with batteries included by default.

Writing Node app in TypeScript

Let us start with an empty Node project.

1
$ npm init --yes

Let us place all TypeScript files into one folder src and output JavaScript code into dist folder. We should also specify the configuration for the TypeScript compiler in tsconfig.json file

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ mkdir -p src
$ cat <<EOF > tsconfig.json
{
"compilerOptions": {
"outDir": "./dist",
"allowJs": true,
"target": "es2015",
"module": "commonjs"
},
"include": [
"./src/**/*"
]
}
EOF

This configuration (that is very simple to change later) outputs modern JavaScript, but transpiles ES6 modules into CommonJS ones. If you want to use ES6 modules (for tree-shaking bundling for example), you can configure a separate step and use "module": "es6" setting. For now, let us just get working Nodejs code.

Install TypeScript compiler (I prefer to install every tool as a local dev dependency rather than globally) and connect it to the build command

1
2
3
$ npm i -D typescript
[email protected]1.0.0 /Users/gleb/trying-typescript
└── [email protected]2.2.1

Update package.json

1
2
3
4
5
{
"scripts": {
"build": "tsc"
}

}

Let us write TypeScript!

TypeScript

There is no magic to TypeScript. In fact, the easiest way to start is to write plain JavaScript! Just give it file extension ts. I will use Lodash and Ramda to form a greeting string

1
2
3
4
5
6
7
8
// src/greet.ts
import {capitalize} from 'lodash'
import {trim} from 'ramda'

export function greet(user) {
const u = capitalize(trim(user))
return `hello ${u}`
}

Let us use the exported function greet from another file.

1
2
3
// app.ts
import {greet} from './greet'
console.log(greet(' world '))

If you hover over greet(' world ') function, the IntelliSense pops up and shows the type signature greet(user: any): string. Notice that we do not know the input type, yet it has correctly inferred the output type - this function returns a string.

Let us build and run the code. Add the start command to package.json file

1
2
3
4
5
6
{
"scripts": {
"start": "node dist/app.js",
"prestart": "npm run build"
}

}

1
2
3
4
5
6
7
8
9
$ npm start
> [email protected]1.0.0 prestart trying-typescript
> npm run build
> [email protected]1.0.0 build trying-typescript
> tsc
> [email protected]1.0.0 start trying-typescript
> node dist/app.js

hello World

Excellent, it works!

Types

What happens if we pass a number to our function greet?

1
console.log(greet(42))

It crashes, of course

1
2
3
4
hello World
/Users/gleb/node_modules/ramda/dist/ramda.js:1286
return str.trim();
TypeError: str.trim is not a function

We can catch this error using TypeScript compiler (and even earlier using VSCode IntelliSense). We can provide type information, for example we can specify that our function greet is expecting a string argument. Then any attempt to call it with a non-string argument will show an error.

1
2
3
4
export function greet(user: string) {
const u = capitalize(trim(user))
return `hello ${u}`
}
1
2
3
$ npm start
src/app.ts(3,19): error TS2345: Argument of type '42' is not assignable
to parameter of type 'string'.

The editor is showing red squiggly lines under console.log(greet(42)) showing the error if I hover it. Helpful!

3rd party types

What about the little functions we are using from Lodash and Ramda? By default, there is no IntelliSense or type information for them

1
2
import {capitalize} from 'lodash'
import {trim} from 'ramda'

Luckily, type definitions for many libraries have been added under @types scope name as part of the DefinitelyTyped project. Just bring these types as dev dependencies

1
2
3
4
$ npm i -D @types/lodash @types/ramda
[email protected]1.0.0 /Users/gleb/trying-typescript
├── @types/[email protected]4.14.55
└── @types/[email protected]0.0.4

Hover over trim(...) call in the greet.ts and notice helpful type definition trim(str: string): string - Removes (strips) whitespace from both ends of the string.

Ambient types

Imagine you have a library written in "classical" CommonJS and you would like to use it from a TypeScript project. You can add ambient type definition file and list types there.

If your library has the main file "main.js", add "main.d.ts" next to it and describe the types. For example

1
2
3
4
5
6
// main.js
const add = (a, b) => a + b
module.exports = add
// main.d.ts
declare function add(a: number, b: number): number;
export = add;

Add link to main.d.ts to your package.json, do not forget to include it in the published package.

1
2
3
4
5
6
7
8
9
{
"name": "my-lib",
"main": "main.js",
"types": "main.d.ts",
"files": [
"main.js",
"main.d.ts"
]
}

Then from your TypeScript project, require the module "my-lib".

1
2
3
// client.ts
import add = require('my-lib')
// add will have type signature from main.d.ts

You can find more examples of writing ambient type definitions in the official docs

Manual types for 3rd party libraries

If the 3rd party library does not provide any types, you can "fill" the missing information yourself. For example, imagine you are importing my-math module which will give you a couple of methods

1
2
3
4
5
6
7
8
// node_modules/my-math/index.js
module.exports = {
add: (a, b) => a + b
// maybe other methods
}
// index.js
const myMath = require('my-math')
console.log('2 + 3 =', myMath.add(2, 3)) //> 5

Tell TypeScript compiler that you want to use your own type definition file

1
2
3
// index.ts
/// <reference path="./libs.d.ts"/>
import myMath = require('my-math')

Write the libs.d.ts file. It should define each module you want to describe and the exported value. The only thing that might trip you up: you will describe an interface of my-math, but export a named value of that interface.

1
2
3
4
5
6
7
8
9
// libs.d.ts
declare module 'my-math' {
interface MyMath {
add: (a: number, b: number) => number
// describe any other methods you want to import
}
const myMath: MyMath
export = myMath
}

TypeScript compiler should be able to use these definition for the loaded CommonJS module. A good idea is to put all your type definitions into a file named typings.d.ts in the root of the TypeScript code.

Linting

I love using standard to enforce consistent code style and even automatically fix small issues. For TypeScript, there is tslint and "standard" set of rules

1
2
3
$ npm i -D tslint tslint-config-standard
[email protected]1.0.0 trying-typescript
└─┬ [email protected]4.5.1

Set the rule in tslint.json file

1
2
3
4
5
$ cat <<EOF > tslint.json
{
"extends": "tslint-config-standard"
}
EOF

and run linter before each build

1
2
3
4
5
6
7
{
"scripts": {
"build": "tsc",
"prebuild": "npm run lint",
"lint": "tslint --fix --format stylish src/**/*.ts"
}

}

Most white space errors will be fixed automatically.

IntelliSense and JSDoc

TypeScript IntelliSense in VSCode and Atom will automatically display JSDoc comments with the type definitions. For example, write a short one liner above the function to be displayed

1
2
/** Adds two numbers or concatenates two strings */
function add(a, b) { return a + b }

Here is how VSCode and Atom shows the type and help comment for a typical function.

VSCode IntelliSense

Atom IntelliSense

Source maps

Imagine there is a crash inside our transpiled code. For example, if the original TypeScript code is

greet.ts
1
2
3
4
5
6
7
import {capitalize} from 'lodash'
import {trim} from 'ramda'

export function greet(user: string) {
const u = capitalize(trim(user))
throw new Error('nope')
}

then running the transpiled code generates a stack trace that does NOT point at line 6 of greet.ts. Instead it points at the transpiled JavaScript code.

1
2
3
4
5
6
7
8
9
10
11
$ node dist/greeter.js

/try-typescript/dist/greet.js:7
throw new Error('nope');
^

Error: nope
at Object.greet (/try-typescript/dist/greet.js:7:11)
at Object.<anonymous> (/try-typescript/dist/greeter.js:4:21)
at Module._compile (module.js:570:32)
...

We need to do 2 things to get the stack traces pointing at the original code.

  1. TypeScript compiler should generate source maps. Go to tsconfig.json and add sourceMap setting.

    tsconfig.json
    1
    2
    3
    4
    5
    {
    "compilerOptions": {
    "sourceMap": true
    }

    }

    Running the tsc compiler now should produce extra ".map" files, and each ".js" file will have source map reference

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    $ cat dist/greet.js
    "use strict";
    Object.defineProperty(exports, "__esModule", { value: true });
    const lodash_1 = require("lodash");
    const ramda_1 = require("ramda");
    function greet(user) {
    const u = lodash_1.capitalize(ramda_1.trim(user));
    throw new Error('nope');
    }
    exports.greet = greet;
    //# sourceMappingURL=greet.js.map
  2. Add source map support to node by installing node-source-map-support module. It is a runtime dependency.

    1
    $ npm install --save node-source-map-support
  3. Import the node-source-map-support at the start of your TypeScript application

    greet.ts
    1
    2
    import 'source-map-support/register'
    import {greet} from './greet'

The crash should now show the original TypeScript locations.

1
2
3
4
5
6
7
8
9
10
11
$ node dist/greeter.js


/try-typescript/src/greet.ts:6
throw new Error('nope')
^
Error: nope
at Object.greet (/try-typescript/src/greet.ts:6:9)
at Object.<anonymous> (/try-typescript/src/greeter.ts:4:13)
at Module._compile (module.js:570:32)
...

This is still imperfect (what is Object.greet?), but at least can be prettified using pretty-error module

greet.ts
1
2
3
4
import 'source-map-support/register'
import * as PrettyError from 'pretty-error'
PrettyError.start()
import {greet} from './greet'

which produces the following (plus colors)

1
2
3
4
5
6
7
8
9
10
11
12
$ node dist/greeter.js

Error: nope

- greet.ts:6 Object.greet
/try-typescript/src/greet.ts:6:9

- greeter.ts:6 Object.<anonymous>
/try-typescript/src/greeter.ts:6:13

- module.js:570 Module._compile
...

There are other themes you can use.

Once you have a folder with original TypeScript files .ts, and generated JavaScript files .js and the source map files .js.map, the file explorer view becomes very crowded.

Transpiled JavaScript and source map files crowding the explorer view

To hide the generated files when there is an existing TypeScript file, open VSCode Preferences, find files.exclude settings object and add the following

1
2
3
4
5
6
{
"files.exclude": {
"**/*.js.map": true,
"**/*.js": {"when": "$(basename).ts"}
}

}

Favorite features

Visual Studio Code has a great feature: hover over a function reference, for example over "greet" in console.log(greet('me')) and select "Peek definition" from right click menu. The modal shows quick function definition!

Peek function definition

Refactoring a code base is also easier with "Find references" feature

Find references

Common problems and solutions

I need to write TypeScript tests

I have described how to transpile TypeScript spec files in Unit test Node in 10 seconds blog post.

Cannot find global symbol describe (Mocha, Jasmine)

When testing using Mocha we tell linter that describe and it are global symbols

foo-spec.js
1
2
3
4
5
6
/* global describe, it */
describe('foo', () => {
it('does something', () => {
// test code
})
})

When moving this code to TypeScript, find if there are Mocha typings using

1
2
3
$ npm info @types/mocha
YES!
$ npm install --save-dev @types/mocha

Remove the lint comment - VSCode and TypeScript compiler will understand the types now automatically.

Cannot redeclare block-scoped variable

When porting JavaScript we can get a cryptic error if multiple files declare same variables. For example if two files require same modules using same variable names we will get an error.

1
2
3
4
const debug = require('debug')('my-module')
const la = require('lazy-ass')
const is = require('check-more-types')
// [ts] Cannot redeclare block-scoped variable 'debug'

To solve I found the solution to use import declaration instead of const. You don't even need to change everything, just put a module that can be imported.

1
2
3
import la = require('lazy-ass')
import is = require('check-more-types')
const debug = require('debug')('screenshots')

Nested folders

To convert multiple files in subfolders and generate same folder tree in the output folder, use rootDir option. For example, to convert all TypeScript files from ts folder and output the results into src directory use

1
2
3
4
5
6
7
8
9
{
"compilerOptions": {
"outDir": "./src",
"rootDir": "./ts"
}
,

"include": [
"./ts/**/*.ts"
]
}

See #2772

Cannot find name process

If you get an error trying to use common Node global symbols, like process with error message error TS2304: Cannot find name 'process' you should install the node typings

1
npm install "@types/node" --save-dev

The VSCode window might need to be reloaded in order to stop showing red squiggly lines.

Cannot find system module

1
import fs from 'fs'

Produces error TS2307: Cannot find module 'fs'

solution

Make sure tsconfig.json file has "moduleResolution": "node" as in

1
2
3
4
5
{
"compilerOptions": {
"moduleResolution": "node"
}

}

Then install Node typings npm install "@types/node" --save-dev

and change the import line to import * as fs from 'fs'.

Promise refers to a type

1
new Promise((resolve, reject) => ...)

produces an error Promise' only refers to a type, but is being used as a value here.

solution

Make sure the tsconfig.json has ast least es6 target type

1
2
3
4
5
6
7
8
{
"compilerOptions": {
"target": "es6"
}
,

"include": [
"./ts/**/*.ts"
]
}

Loading JSON files

To load a JSON file, like we do in JavaScript const pkg = require('./package.json') you need to define a wildecard module. Just create a typings.d.ts file with

1
2
3
4
declare module '*.json' {
const value: any
export default value
}

and then import the file. When you are ready to grab a property from the loaded object, cast it as "any" or a specific interface

1
2
import * as pkg from './package.json'
console.log('package name', (pkg as any).name)

See the blog post How to Import json into TypeScript

Alternatively, you can still use require to load JSON files, but you would need to add Node type definitions

1
$ npm install @types/node --save-dev

CPU is fried

Fun fact: VSCode uses a lot of CPU when rendering blinking cursor. Disable it in the preferences

1
"editor.cursorBlinking": "solid"

Atom editor

To add TypeScript support to Atom editor, I recommend atom-typescript. The first time you open a .ts file, atom-typescript will ask if you want to install linter plugin. Click "Yes" and let it install.

I must admit, Atom TypeScript support via plugin is not as good as in VSCode. The notifications often just showed the error, but without providing relevant help. For example, as I am typing arguments, Atom only shows that there is an error, but not the actual signature!

VSCode help

Atom help

Additional information

A few links to why and how you should use TypeScript