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] /Users/gleb/trying-typescript
└── [email protected]

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] prestart trying-typescript
> npm run build
> [email protected] build trying-typescript
> tsc
> [email protected] 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] /Users/gleb/trying-typescript
├── @types/[email protected]
└── @types/[email protected]

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] trying-typescript
└─┬ [email protected]

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.

Linting using TypeScript compiler

You can also lint (without auto-fixing) TypeScript files using tsc itself. Just specify options to avoid outputting JS

1
2
3
4
5
{
"scripts": {
"lint": "tsc --pretty --noEmit cypress/**/*.ts"
}
}

Example output:

1
2
3
4
5
6
7
8
9
10
11
> tsc --pretty --noEmit cypress/**/*.ts

cypress/integration/spec.ts(1,1): error TS2304: Cannot find name 'it'.

1 it('works', () => {
~~

cypress/integration/spec.ts(2,3): error TS2304: Cannot find name 'cy'.

2 cy.document()
~~

We can solve the first problem (missing global it function) by adding Mocha TypeScript definitions with npm i -D @types/mocha. We can solve the second by installing version of Cypress that includes type definitions (should be > 1.1.3) or by installing definitions from deprecated library npm i -D @types/cypress.

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 just want to run TypeScript files in Node

Use ts-node

I want ts-node with auto reload

Use ts-node-dev

Ignore specific error

If you are running tsc and getting annoying errors, like "unused variable", you can ignore specific error for specific line. For example

1
2
3
4
// variable "i" is unused by needed. Ignore this specific error.
// @ts-ignore TS6133
let texts = $p.map((i, el) =>
Cypress.$(el).text())

Iterable

If TypeScript compiler cannot find Iterable or similar things, then in the tsconfig.json add to lib

1
2
3
4
5
{
"compilerOptions": {
"lib": ["es2015"]
}
}

If it cannot find things like window, HTMLElement, then add dom to the lib list.

I need to write TypeScript tests

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

Ava

Another good solution is to use Ava test runner but via ava-ts. Then you can write TypeScript tests with zero configuration. Even running a single spec file is simple using npx tool.

1
$ npx ava-ts spec/foo-spec.ts

Mocha

If you use Mocha, you can write your spec files in TypeScript and use ts-node as a preprocessor for Mocha

1
$ npm i -D ts-node
package.json
1
2
3
4
5
{
"scripts": {
"test": "mocha -r ts-node/register src/**/*-spec.ts"
}
}

Jest

If you want to use Jest, use it via ts-jest. Install with command

1
$ npm install --save-dev jest ts-jest @types/jest

The add config block to package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"scripts": {
"test": "jest"
},
"jest": {
"transform": {
"^.+\\.ts$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.ts$",
"moduleFileExtensions": [
"ts",
"js"
]
}
}

If your spec files all are next to the source files, for example in src folder and all end with -spec.ts, the configuration could be very simple

1
2
3
4
5
6
7
8
9
10
11
12
{
"jest": {
"transform": {
"\\.ts$": "ts-jest"
},
"testRegex": "src/.*-spec\\.ts$",
"moduleFileExtensions": [
"ts",
"js"
]
}
}

Note: do NOT remove "js" from the moduleFileExtensions list - it will break Jest loader! Now create folder __tests__ and write test file __tests__/foo-test.ts and the tests should have type support.

Note: if the test run returns error without explanation like the one below, make sure you have tsconfig.json (generate it with npx tsc --init command)

1
2
3
● Test suite failed to run

Error: No message was provided

Wallaby.js + Jest

I have working example with Wallaby.js test runner using Jest and TypeScript in https://github.com/bahmutov/test-wallaby

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')

Another solution people suggest is tell TypeScript compiler that these are ES6 modules by exporting from the top level of the file. Just add a "dummy" export statement at the top level of the file

1
export {}

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

Objects with values of same type

To avoid losing type information when storing values in an object, you need to provide type definitions. For example if we want to have Group that can only have Person values we would need to declare the type of the group object explicitly.

1
2
3
4
5
6
7
8
type Person = {
name: string
}
type Group = {
[key: string]: Person
}
const group: Group = {}
// group can only store instances that are compatible with "Person"

Unfortunately you cannot use type aliases as keys in type declarations. For example if we store people using social security numbers we cannot do this (yet as of TS v2.8.0).

1
2
3
4
5
6
// type alias
type SSN = string
type Group = {
[key: SSN]: Person
}
// sorry key must be a string

Provide types during destructuring

If you need to specify types during object destructuring, like the type for message in the example below you need to declare the entire object. See Typing Destructured Object Parameters in TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// how to specify type for "message"?
function ({message}) {
console.log(message)
}
// does not work!
function ({message: string}) {
console.log(message)
}
// works
function ({message}: {message: string}) {
console.log(message)
}
// provide default value
function ({message}: {message?: string} = {message:'hello'}) {
console.log(message)
}

Cannot find property "find"

If the TypeScript cannot find property find on an array instance, like here

1
2
[1,2,3].find(_ => _ === 3)
// Property 'find' does not exist on type 'number[]'

Then create tsconfig.json file and set the target property to es6

1
npx tsc --init
tsconfig.json
1
2
3
4
5
{
"compilerOptions": {
"target": "es6"
}
}

Cannot find name 'require'

If you get this error

1
error TS2304: Cannot find name 'require'.

It means TypeScript compiler does not understand that you are working in Node environment. Just add its types

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

Ramda types are not working

If you are trying to use Ramda library and the types get "lost"

  • add npm i -D @types/ramda to bring Ramda's types
  • use import {prop} from 'ramda' instead of const {prop} = require('ramda') to get correct operators

Read only objects and arrays

You can prevent accidental mutations using Readonly and ReadonlyArray types.

Readonly object

For deeper freeze, you need to use Readonly on each property

Readonly value inside an object

Use ReadonlyArray to prevent changes, even mutation methods are disabled

Readonly array

Non-empty array

Here is how to declare a type for an array that should never be empty

1
2
3
type UnemptyArray<T> = [T, ...T[]]
const strings: UnemptyArray<string> = ['foo', 'bar'] // ok
const numbers: UnemptyArray<number> = [] // error

Note that you can still change the length of the array to make it empty at run time.

1
strings.length = 0

And I could not find a way to combine readonly arrays with non-empty arrays :(

Debugging TypeScript from VSCode

If you are using VSCode to edit TypeScript code used with ts-node, you can set break points and quickly debug the program's execution. Create .vscode/launch.json file and define a command there

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"args": "${workspaceFolder}/src/app.ts",
"runtimeArgs": [
"-r",
"ts-node/register"
]
}
]
}

The above config loads node -r ts-node/register src/app.ts and everything should just work!

Adding property to global object

To describe additional properties your code adds to Node.js global object describe changed properties like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
declare global {
namespace NodeJS {
interface Global {
api: {
foo: string
}
}
}
}
// now can use "global.api" property
global.api = {
foo: 'foo'
}
// and you can still use all built-in global properties

TypeScript will merge your NodeJS.Global instance with the built-in one.

Additional information

A few links to why and how you should use TypeScript

Tutorials

Blog posts and videos