In this chapter, we will look at TypeScript and see how to use TypeScript in a new project or an existing JavaScript project. We will see how to adapt the environment of your current build setup. TypeScript can be supported by a variety of build tools, such as Grunt, Gulp, webpack, or simply by using the command-line interface (CLI). We also look at the best options, among all the ones available, for getting started with TypeScript.
Before configuring any build tool, it's important to understand that all of them use the same TypeScript compiler, often called a transpiler. The TypeScript compiler is available by using npm:
npm install -g typescript
Npm might not be installed by default on your computer. If that is the case, the previous instruction will fail. It means you need to install Node.js. You can install Node.js by going to the official website, https://nodejs.org/.
At any time, you can verify that you have Node.js, npm, and TypeScript installed by using the following command:
node -v npm -v tsc -v
TSC is the executable for the TypeScript compiler. This is the one that is used by all build tools. Grunt, Gulp, and webpack use TSC via their own plugin infrastructures that map TSC features to their platform. Note that recent TSC features might take a few weeks before reaching these platforms. This might explain the differences in compiler options when using these three build systems. In contrast, using a TSC CLI ensures that you are using TypeScript directly.
This chapter covers the following:
- Grunt
- Gulp
- Webpack
- NMP/CLI
- TypeScript compiler
Grunt is a JavaScript task runner. It can be installed using NPM, which is used to list all its plugins:
npm install -g grunt-cli
In the case of a new project, make sure that package.json
exists in the root of your TypeScript project. You can generate a simple one by using npm init
.
Once it is done, you can install Grunt into your project:
npm install grunt --save-dev
Once Grunt is available on your machine and specified in your project, you need to get a TypeScript plugin. Grunt has two plugins named grunt -TypeScript
and grunt-TS
. The former has not been maintained for a few years and lacks the latest TypeScript compiler configuration. I strongly suggest using the latter:
npm install grunt-ts --save-dev
The last package should be installed as a dev dependency for Grunt to compile TypeScript and to install it locally. Grunt will search for the package locally. Omitting TypeScript as a local dependency will result in the following error when executing Grunt.
Note
ENOENT
: no such file or directory, open '/.../node_modules/grunt-ts/node_modules/typescript/package.json'
Use --force
to continue.
Installing TypeScript locally as a dev
dependency is easy:
npm install typescript --save-dev
Prior to grunt-ts
version 6, TypeScript and Grunt were installed during the installation of grunt-ts
. This is not the case anymore, so they must be added manually.
The next step is to configure Grunt to use a TypeScript plugin. If you are not using Grunt, you need to create a Gruntfile.js
at the root of your project. Otherwise, you can edit your existing one. The plugin allows you to specify many TypeScript options in the Gruntfile.js
, but a good practice is to limit TypeScript options directly in the file and to leverage the TypeScript configuration file. By configuring TypeScript outside Grunt, this gives you the possibility of compiling your code without Grunt, or migrating to another build tool without having to duplicate or change TypeScript preferences.
A minimalist Grunt configuration with the sole purpose of compiling TypeScript into JavaScript may look like the following:
module.exports = function(grunt) { grunt.initConfig({ ts: { default : { tsconfig: './tsconfig.json' } } }); grunt.loadNpmTasks("grunt-ts"); grunt.registerTask("default", ["ts"]); };
The Grunt configuration creates a default task that executes a custom ts
task that links to the tsconfig.json
file, which is the default TypeScript configuration file.
The tsconfig.json
file can look like the following one, which takes every TypeScript file with the extension .ts
and will compile them outputting the result in the build
folder:
{ "compilerOptions": { "rootDir": "src", "outDir": "build", } }
When using grunt
and grunt-ts
, you must ensure that that the JSON is valid with no-trailing commas in the tsconfig.json
file. Otherwise, you may get the following error:
tsconfig error: "Error parsing \"./tsconfig.json\". It may not be valid JSON in UTF-8."
To test the configuration, create a simple index.ts
file in an src
folder at the root of the project. You can type console.log('test')
. After, run grunt
in a command line at the root of your project as well. This will create a build
folder with an index.js
file containing the same line of code. It will also create the js.map
file that will let you debug in your browser directly in TypeScript's code.
If, for some reason, you do not want to rely on tsconfig.json
, it's possible to specify the source and destination directly into Gruntfile.js
file:
module.exports = function (grunt) { grunt.initConfig({ ts: { default: { src: ["src/**/*.ts"], outDir: "build", options: { rootDir: "src" } } } }); grunt.loadNpmTasks("grunt-ts"); grunt.registerTask("default", ["ts"]); };
In the end, grunt-ts
wraps the TypeScript command line. It provides options such as the fast compilation, which compile, only what has changed since the last compilation. It is also an interesting option if you are already using Grunt in your project and want to start using TypeScript without modifying your build process.
Gulp is an automation toolkit that has a TypeScript plugin as well. There are two plugins available in NPM, which are gulp-tsb
and gulp-typescript
. The latter is the most popular and more maintained. You can fetch gulp
and the plugin by using the following command:
npm install -g gulp npm install --save-dev gulp-typescript
If you do not have a Gulp configuration file, you will need to create one at the root of your gulpfile.js
project.
The configuration without an explicit option will rely on the default configuration. It means that configuring Gulp can be as simple as piping the source into the TypeScript plugin and then piping the result into the destination folder where the build
files, that is the JavaScript file, will be placed for consumption. Once the following code is placed in gulpfile.js
, you can execute it by using gulp
in the command line. This will execute the default task once, automatically:
var gulp = require("gulp"); var ts = require("gulp-typescript"); gulp.task("default", function () { var tsResult = gulp.src("src/**/*.ts") .pipe(ts()); return tsResult.js.pipe(gulp.dest("build")); });
It is possible to have a task in Gulp to build incrementally a TypeScript file that changes instead of building all of them. This can be useful on a big project to reduce the time between the edition and the access to the result. This is similar to the fast compilation of Grunt. To have an ongoing compilation, you must create a new Gulp task. In this example, we will change Gulp to rely on tsconfig.json
file, which will allow us to separate the TypeScript compiler option from the Gulp configuration:
var gulp = require('gulp'); var ts = require('gulp-typescript'); var tsProject = ts.createProject('tsconfig.json'); gulp.task('scripts', function() { return gulp.src('src/**/*.ts') .pipe(tsProject()) .pipe(gulp.dest('build')); }); gulp.task('watch', ['scripts'], function() { gulp.watch('src/**/*.ts', ['scripts']); });
To run the watch
task, you need to execute Gulp followed by the name of the task: gulp watch
. Unlike Grunt, Gulp will not produce the map
file. It requires an additional Gulp plugin. Because the sourceMap is crucial to have an efficient debugging environment, it is keen to download the gulp-sourcemap
package and change the previous configuration to the following. But first, let's download the gulp-sourcemaps
package:
npm install --save-dev gulp-sourcemaps
And then create a new task:
var sourcemaps = require('gulp-sourcemaps'); gulp.task('scriptswithsourcemap', function () { return gulp.src('src/**/*.ts') .pipe(sourcemaps.init()) .pipe(tsProject()) .pipe(sourcemaps.write('.', { includeContent: false, sourceRoot: '.'})) .pipe(gulp.dest('build')); });
The configuration will create the source map in a file with the same name as the JavaScript file generated but with a different extension. The extension will be .jsmap
. If you want to have the mapping directly in the JavaScript file, you can remove the two arguments passed in the write
function. I suggest having a single script task that produces the source map in a file to separate the mapping from the code generated, and to always have the source map created. It's a small tax on the compilation and a huge gain in debugging.
Webpack is one of the most commonly used ways to automate workflow when working with JavaScript and web development. Its main purpose is to bundle, but it can do many sequential steps, such as compiling TypeScript. Similarly to Grunt and Gulp, webpack has two loaders (similar to a plugin) for TypeScript. One is called ts-loader
and the second awesome-typescript-loader
. While with Grunt and Gulp, it was a clear which one users prefer, this is not the case with Webpack. Both loaders are similar in terms of popularity. It is also not difficult to change between the two if needed. Originally, awesome-typescript-loader
was faster than ts-loader
but with the evolution of TypeScript, the difference is often minimal. Also, there is sometimes an issue with an advanced feature in one or the other, and so it is practical to be able to switch depending on how your project. I'll present ts-loader
, which is a little more popular, still actively maintained, and has a little more usage than the awesome-typescript-loader
.
In the case, you are not yet using webpack
, we need to install it:
npm install --save-dev webpack npm install --save-dev webpack-cli
Once webpack is installed, you can install the TypeScript loader:
npm install --save-dev ts-loader
Once all the tools are installed, you can configure webpack to bundle the JavaScript produced by the webpack
loader. However, webpack.config.js
is needed at the root of your project. Like any Webpack configuration, the entry property must be defined. Make sure you are referring to the TypeScript file. The output is also specified in the output property. Webpack requires mentioning the extension to be analyzed. In TypeScript case it is .ts
, but if you are working with React you might want to also add .tsx
under resolve:extensions
. Finally, the ts-loader
is specified under module:rules
. Once again, the extension of TypeScript is required and the name of the loader:
module.exports = { mode: "development", devtool: "source-map", entry: "./src/index.ts", output: { path: __dirname + "/build", filename: "bundle.js" }, resolve: { extensions: [".ts"] }, module: { rules: [ { test: /\.ts$/, loader: "ts-loader" } ] } };
You can run the webpack command line (cli
) by accessing the binary file, which will read the webpack.config.js
file:
node node-modules/webpack-cli/bin/cli.js
If you want to avoid referencing node_modules
, you can install webpack-cli
in your global space, using npm install -g webpack-cli
.
Here are a few little details about webpack. There is an additional module that divides the production of the bundle and compilation, compared to just validating TypeScript. These modules might be interesting when your project starts to grow and you want to have a faster compilation pace. Feel free to check fork-ts-checker-webpack-plugin
and thread-loader
. Before diving into other libraries, ts-loader
has a way to incrementally build and to use the TypeScript watch API to avoid building everything all the time. This will increase your performance on every compilation. To allow the watch, change the rule of ts-loader
to the following:
rules: [ { test: /\.ts$/, use: [ { loader: 'ts-loader', options: { transpileOnly: true, experimentalWatchApi: true, }, }, ], } ]
A final detail about webpack is its dependency on tsconfig.json
for all TypeScript related configurations. Grunt and Gulp allow you to override configurations inside their tool configuration, which is not the case with webpack. When bundling, webpack produces bundle.js.map
, but only if the dev tool specifies a configuration. However, you must set the tsconfig.json
"sourceMap" to true
to have a mapping that works with TypeScript.
Almost all web projects use NPM. NPM is the mechanism we used to fetch TypeScript. This one creates package.json
at the root of your project and can be used to launch TypeScript directly. This is possible because TypeScript has a CLI called tsc (TypeScript compiler).
NPM configuration has a section named scripts
where you can add any command you want. You can create a build
one that invokes tsc. Without any parameters, tsc uses tsconfig.json
at the root of your project. In the following snippet, the "build" script is defined. To run the command, the use of the run
command of NPM is needed, that is, npm run build
:
"scripts": { "build": "node_modules/typescript/bin/tsc" },
With a TypeScript configuration file that specifies the source map, the rootDir
, and outDir
the result will be the same as Gulp and Grunt (different from webpack since it won't be bundled):
{ "compilerOptions": { "rootDir": "src", "outDir": "build", "sourceMap": true } }
This is often not the preferred configuration because of how simplistic and limited it is. However, it's possible to have several commands executed one after the other, using the double ampersand (&&
) to create a chain of commands. This option is fast, doesn't require any dependency on NPM libraries, and is often enough to get started at a basic level.
The advantage of the NPM and CLI approach is that TypeScript can be executed easily. Hence, if you have a custom build system you can easily plug TypeScript by invoking the CLI.
Considering all the tooling available to compile TypeScript to JavaScript, one pillar concept remains the same: you must know which configuration to use. Insofar that you are not responsible for configuring the compiler, you could skip this section – configuring TypeScript is something you do rarely and when it works as desired it can stay unaltered for a very long time. However, to have an understanding of the capability of TypeScript, you need to know some of the core options. In this section, we will see the main settings that you can enable and customize for your project.
This section is all about the configuration of files in the file system. It guides TypeScript on where to find different files in your machine, as well as where to generate JavaScript files.
The most basic configuration that you need to set for your project is to indicate to TypeScript where to get TypeScript files and where to publish the result of the compilation. Where will be the TypeScript (.ts
) files and the JavaScript (.js
) files be produced. This is done by specifying rootDir
and outDir
. Avoiding rootDir
might give you a surprise in outDir
. By default, TypeScript computes what it should be and tries to find a common path, which is the longest common prefix of all your input files. That has the drawback of being inconsistent when the file structures change. Recently, TypeScript changed its behavior to have a default to .
, which alleviate the issue. Nevertheless, having an explicit configuration is the best practice to avoid confusion as to which version of TypeScript this new rule was applied.
Example:
rootDir:src outDir:build
Confusion can arise when the baseUrl and paths come into play. The baseUrl allows specifying with a non-relative name to be resolved. Paths work closely with the baseUrl and is a map of key-value allowing a name to be used as a link to a specific path to the library, using the baseUrl as the root.
Here is an example:
{ "compilerOptions": { "baseUrl": ".", // This must be specified if "paths" is. "paths": { "jquery": ["node_modules/jquery/dist/jquery"] // This mapping is relative to "baseUrl" } } }
In code:
Import * from "jquery"
Paths can also be used for more advanced scenarios where you can define fallback folders. As good practice, I would advise using a relative path as much as possible and avoid complicated structures and potential resolving issues.
The sourceMap property is a boolean that when set to true
will generate the mapping between the generated JavaScript and TypeScript. This is a good option to turn on if you do debug in a browser and want to step in the TypeScript code instead of stepping into the generated code. It simplifies the debugging because you are working in exactly the same area. This is most of the time turned on.
However, sourceRoot is rarely used in normal circumstances. It is available if you move the sourceMap somewhere else to indicate at runtime where to find the sourceMap. This will alter the generated sourceMap path. The following code shows a comment indicating the path of the map file. SourceRoot would change the portion beforeindex.js.map
:
const text = "Text for test1"; console.log(text); //# sourceMappingURL=index.js.map
Similarly, mapRoot allows changing the source if the map files are in a different place than the JavaScript file. The difference between sourceRoot and mapRoot is this time we alter the map file instead of the JavaScript file. In the following partial extraction of a code of a map file, we see paths that can be modified by mapRoot:
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"]......
The moment to use or the other depending on how you configure positions your built files. If you move the map somewhere else, then sourceRoot
is interesting. However, if you keep the map but move the JavaScript somewhere else than you may change mapRoot. I mention these configurations for the sole reason that you may already have a JavaScript project that you want to migrate to TypeScript. Depending on your existing configuration, you may need to tweak these configurations. However, for any standard project, no modification should be made to these configurations.
Included is an array that specifies the glob pattern that in turn specifies which files/folders are to be included in the compilation. The array exclude
complements include
and removes files to be compiled. When both properties are specified, exclude
will filter out from the list included files from include
. By default, include
includes all TypeScript files under the rootDir
, hence no need to add an entry to **/*.ts
.
Files are rarely used because it is less flexible than include
. It allows specifying by path and name which file to compile, instead of using a glob pattern. The pattern approach is more flexible allowing you to configure once instead of having to continually modify the configuration file by adding and removing entries in Files.
Here is an example:
"include": [ "src/**/*" ], "exclude": [ "node_modules", "dont/compile/*.mock.ts" ]
A final word on these three file configurations is that, contrary to most options, these ones don't reside under compilerOptions
but are directly set at the root of the tsconfig.json
file.
The outfile is an option that can be useful if you have a need to generate a single JavaScript file from many TypeScript files. When using the outfile, you can remove outDir
and set a path relative to the root of your project, followed with the name and the extension of the generated file:
{ "compilerOptions": { "rootDir": "src", "outDir": "build", "target": "es6", "sourceMap": true, "outFile": "build/mySingleFile.js" } }
The example code above creates a single file but also the sourceMap
file because of the sourceMap
.
This section contains information about TypeScript's type. The first configuration gives TypeScript a hint as to where to look for types and also if TypeScript must generate the definition file or not when compiling.
By default, every type provided inside a node_modules
library, which includes all specific @types/
and package with .d.ts
directly inside the library's folder, are read by the TypeScript compiler. However, in some scenarios where there is no definition file available and you need to provide a custom one then you need to specify where the definition file is located. This can be done by using in which you can specify a folder where you define all your definition files. The caveat is that you will need to specify node_modules
if you want TypeScript to keep look for definition files in node_modules
.
{ "compilerOptions": { "typeRoots" : ["./typings", “./node_modules”] } }
The Types configuration allows cherry-picking which file TypeScript will include. It works in collaboration with typeRoots and is an array. It whitelists the type name.
If you are building a library instead of a website or program, it might be wise to provide the definition file along with the generated JavaScript file. The reason is that when building a library in TypeScript, we never share the actual TypeScript (.ts
) files but instead share the JavaScript files. The rationale is that TypeScript is just a superset of JavaScript and we want to expose our code to the largest audience available. By providing the JavaScript files we are allowing every JavaScript developer to consume our work. However, TypeScript coders are at rest. To fix this issue, we can provide a definition file (.d.ts
) that contains all the signature functions as well as exported variables. The TypeScript compiler lets you generate the definition file automatically by using declaration
.
The option is boolean
:
{ "compilerOptions": { "declaration" : true } }
By default, the declaration files produced are by TypeScript file and located at the same place as the TypeScript file. It means that the end result is for each JavaScript (.js
) you will see a brother declaration file (.d.ts
) next to it:
{ "compilerOptions": { "declaration" : true, "declarationDir": "definitionfiles/here" } }
There is a caveat with declarationDir
, which is that it cannot be used with outFile
. You will get a compilation-error mentioning that both options cannot be defined at the same time:
error TS5053: Option 'declarationDir' cannot be specified with option 'outFile'.
TypeScript has its own configuration file that is a convenient way to avoid passing every option by command-line arguments. The file resides at the root of your project. One possibility is to have several configuration files that can be used in different situations. This is possible by providing tsc with the option -p
followed by the name of the configuration. The following three command-line invocations show one without any parameter, which is doing exactly the same compilation as the second line. Nonetheless, the third compilation instruction is different, pointing to a completely new set of options:
tsc tsc -p tsconfig.json tsc -p tsconfig.test.json
One benefit of configuration files is the possibility of reusability by extending configuration. You can see this principle like object-oriented inheritance – one file can inherit from another one. This can be done by using the extends
property as a key and the file to inherit from as a value. The file provided must be relative to the root of your project.It can or not have the extension (.json
):
{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "buildtest", "sourceMap": false, "declaration": false } }
The following example shows a command that invokes a compilation with the option in tsconfig.test.json
and has a small set of instructions:
tsc -p tsconfig.test.json
The first one is which file to extend. In that case, it could also have been ./tsconfig
without the extension. The file overrides outDir
that is also provided in the tsconfig.json
file and adds additional values. A good pattern is to have a base
configuration, which has a configuration that you know will be shared across many of your configurations.
A key concept of code separation in JavaScript is a module. A module brings the notion of importing and exporting code. The capability increases the ability to share the code by specifying a specific name and which part of a code may be exported. Then, other software can import the code and leverage its functionalities. However, there is not a single way to craft a module. TypeScript lets you write your code in a single way and to produce, during compilation, an output that respects different popular module syntax. Here is a list of modules that TypeScript can interpret:
"None", "CommonJS", "AMD", "System", "UMD", "ES6", "ES2015" or "ESNext".
The module
option can be seen as how TypeScript produces the module and moduleResolution
as how it reads a module. There are two ways that TypeScript can understand an import
statement: class and node. The former is the traditional TypeScript way, which has different rules to find a file that is imported. The more popular choice is node
.
Regardless of the module resolution option, you should try to use relative resolution by specifying in your import a path from the file you are importing. Relative import is denoted by having a path that starts with a single dot or a double dot to move backward. Here are few relative imports:
import x from "./sameFolder"; import y from "../parent/folder"; import z from "../../../deeper/";
The reason for using this is the clarity of where the code is imported. Using the absolute resolution brings confusion because it relies on other configurations like baseUrl
as well as moduleResolution
.
On the contrary, an import that relies on a more complex resolution is non-relative and looks like the following:
import a from "module123";
Without going into all the complex rules, the last example in classic would look for the module next to the file that is importing it and go down the folder structure without trying to import the module from node_modules
. However, if moduleResolution
is set to node
than the first check would be to look-up in the node_modules
folder for a module123
. As you see, if you are using a common name, you may load an unexpected module.
This section contains Typescript configurations related to the type of ECMAScript produced, as well as additional packages that can be incorporated.
The target option must be specified but rarely changed. This option indicates to TypeScript which version of the JavaScript files to produce. By default, it produces an ECMAScript 3 version, which doesn’t have all the built-in features that TypeScript allows. However, TypeScript can still produce such old versions of ECMAScript by producing JavaScript code that mimics the features. This comes with a price of performance penalty at runtime, but it is a great way to build modern code with an older browser. Here is the actual target that you can specify:
"ES3", "ES5", "ES6"/"ES2015", "ES2016", "ES2017" or "ESNext"
If you are deploying for the web in general, ES5 is a safe bet with 100% support for all browsers. But, ES6 is very close, and Chrome supports 98% of its features, Firefox 97%, and Edge 96%.
TypeScript can inject the core library of ECMAScript into the produced code. By default, some libraries are automatically added. For example, if you specify a target of ES5, TypeScript adds the library: DOM, ES5, and ScriptHost. You can manually add an additional library. For example, if you would like to use iterable you can add the string ES2015.Iterable
in the lib array. You can use a feature that is beyond your main target as well. For example, you can have a target of ES2015 and uses an ES2018 feature. See "target" as a main set of features to be included in the compilation and lib
as a subset of additional features that you can add to the compilation.
TypeScript has many option around how strict the compiler must analyze your code. This section shows you the difference between each setting, allowing you to start slowly and progressively if you are coming from an existing JavaScript.
This is the option that turns every configuration to strict. This is what you should use if you start a new TypeScript project coming from JavaScript.
This is an advanced check that does not allow bivariance for arguments of a function. It uses contravariance. What it means is that if a function is expecting a type A as a parameter, you cannot set a function that has a type B that inherits a type A – you must only pass a type A. The following won't compile with StrictFunctionTypes
with a true
value. The strict option is useful to avoid a passing object that has more members than the expected type. The following example has B
as the firstName
field and inherits A
, hence the name:
interface A { name: string; } interface B extends A { firstName: string; } declare let f1: (x: A) => void; declare let f2: (x: B) => void; f1 = f2; // DOESNT COMPILE f2 = f1;
During compilation, TypeScript finds that the argument is passed to an object with more members and won't compile:
Error message : Type 'A' is not assignable to type 'B'. Property 'firstName' is missing in type 'A'.
StrictPropertyInitialization
property should always be set to true. It ensures that all properties of a class are initialized with a direct association at the declaration level or in the constructor of the class.
class A { public field1: number; }
The example does not compile because Field1
is a number that is not defined. The value of the field is undefined. There are many solutions to keep the strictness and make the code compliant. The first solution is to set a value at the initialization:
class A { public field1: number = 1; }
Setting a default value at initialization is not always possible. In some cases, it's possible to specify the value at construction type:
class A { public field1: number; constructor(p:number){ This.field1 = p; } }
A third way to compile the code is to use the bang operator (!
) after the member's name. The operator indicates to TypeScript that the value will be provided later. The scenario in which a late initialization occurs is often by some injection framework or by using a function to initialize the class.
One caveat to having strictPropertyInitialization
do this job is a dependency on another strict property that must be enabled – the strictNullChecks
. The null check should also always be set to true at all times. Without this, a field identified as a type will automatically accept null and undefined as a valid type. It is less confusing and more declarative to only support a field with an explicit type and in the case of null or/and undefined to use the property definition that we will see later in this book.
In this chapter, we put in place different configurations allowing you to start coding with TypeScript in a straightforward way. After setting up your working environment to your liking, we briefly mentioned the most important compiler options to get you started on the right path. TypeScript is a flexible compiler, and you should be rapidly up-to-speed developing because of how the settings can be selected.
In the next chapter, we will look at programming with TypeScript by introducing how ECMAScript primitive type can be strongly typed.