Skip to content

Instantly share code, notes, and snippets.

@thomasboyt
Created April 7, 2016 21:09
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomasboyt/07d675e76bef518ffbed97a0a4076598 to your computer and use it in GitHub Desktop.
Save thomasboyt/07d675e76bef518ffbed97a0a4076598 to your computer and use it in GitHub Desktop.

How I Build TypeScript

(super early draft)

I decided to add TypeScript to an existing JavaScript codebase, which ended up being one of the more terrible decisions of my life. Don't get me wrong, TypeScript-the-language seems great, and I'm excited to be able to improve my code using it. On the other hand, TypeScript-the-tool and TypeScript-the-ecosystem leave a lot to be desired.

This document covers how I integrated TypeScript into my build and tooling.

The Project

I integrated TypeScript into a project I'm working on called manygolf. It's a fairly simple project, a little web-based golf game that involves some universal JavaScript to share code between a client and a server.

The project was using Webpack and Babel to build. On the server, I was being lazy and just using the babel-runtime transform to compile the server code when I ran, which ended up biting me later. On the client, a Webpack watcher compiled the app using a fairly bog-standard config:

var createVendorChunk = require('webpack-create-vendor-chunk');

module.exports = {
  entry: {
    app: './src/client/main.js',
  },

  output: {
    path: './build/',
    filename: '[name].bundle.js'
  },

  plugins: [
    createVendorChunk()
  ],

  resolve: {
    alias: {
      '__root': process.cwd(),
    },
  },

  devtool: 'source-map',

  module: {
    loaders: [
      {
        test: /\.js$/,
        exclude: /(node_modules\/)/,
        loader: 'babel-loader',
      },
    ]
  },

  // (snip dev server config and more asset loaders...)
};

Adding TypeScript proved an... interesting challenge, to say the least. In the past, I'd added Flow to a project with extreme ease, as its "compiler" is simply a Babel transform (type-checking itself takes place in a separate process).

On the other hand, TypeScript is a relative monster by comparison. I've tried hard to not be judgemental of how it was built, recognizing that it existed long before Babel or Webpack had any momentum. But, well, in 2016, it's incredibly awkward to work with.

Basically, TypeScript seems to expect that you want to build code through your editor. I guess this makes sense if you're used to big ol' IDEs like Visual Studio that control every step of your workflow, but as someone who was used to building projects with terminal commands open in tmux splits, this didn't set well with me. I didn't want to be locked into some specific editor to build my application (whether VS, or Atom, or Vim and a whole bunch of plugins).

Not only that, but TypeScript seems to think that it should be your only compiler. It can compile ES6 to ES5 on its own, which is rather pointless in 2016 now that Babel exists. I suppose I could see this being useful if I had a fully-TypeScript project and didn't want to introduce Babel, but given that I was adopting TypeScript in an existing project, I didn't want to break my existing Babel compilation step.

Step One: Installing Things

First up, I had to grab the ts-loader plugin for Webpack and the TypeScript compiler:

npm install --save-dev typescript ts-loader

Then I had to install the typings tool. Typings is basically a package manager for TypeScript type definitions. Theoretically, packages can ship with typings defined in by a field in package.json, but basically no one does this. Instead, you have to search for definitions:

$ typings search redux

NAME                            SOURCE HOMEPAGE                                                   DESCRIPTION UPDATED                  VERSIONS
redux                           npm    https://www.npmjs.com/package/redux                                    2016-02-14T19:48:20.000Z 1
redux                           dt     https://github.com/rackt/redux                                         2016-03-26T11:26:56.000Z 1
...

Now, if you're looking at this and thinking "gee, sure is weird that there are two different definitions:" apparently, definitions can either be specified by (a) an NPM package's package.json field having a "typings" field that defines a file of definitions, or (b) a third-party type definition on DefinitelyTyped. Either way, you have to run either:

# get the npm definition
$ typings install --save redux
# or for the dt definition
$ typings install --save --ambient redux

Passing --save will save the type definition to typings.json, kinda like npm install --save. Theoretically this would save you from vendoring your third-party definitions inside your repo, but I'm still doing that because I don't trust this typings thing and because everyone else seems to do that anyways.

I used the DefinitelyTyped definition since the official Redux one was missing a bunch of things. It's probably better on master now, I dunno.

As an aside, while I get why you'd use this for DefinitelyTyped definitions, I'm not really sure why you'd have to use typings install for packages on NPM, considering that TypeScript is supposed to be able to just look at the package.json itself and get the type definitions, instead of this Typings thing having to install it. But if I try to just use Redux without installing the definition through typings, it says it can't find the Redux module. Maybe this is a ts-loader bug? It also has issues with other packages that Atom's TypeScript plugin can find just fine, so I'm going with that.

Step Two: tsconfig.json

With TypeScript and type definitions installed, we add a tsconfig.json file to our project root:

{
  "compilerOptions": {
    "target": "es6",
    "module": "es2015",
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "sourceMap": true,
    "noEmit": true,
    "outDir": "tmp-see-ts-loader-issue-171"
  },
  "compileOnSave": false,
  "exclude": [
    "node_modules",
    "typings/main.d.ts",
    "typings/main"
  ]
}

This thing took forever for me to figure out, so I'll break it down a line at a time.

  • target: This specifies the language version you want to output. We want to compile our JavaScript from ES6 to ES5 with Babel, not Webpack, so we use "es6" instead of the "es5" default.

  • module: This is the module format that Webpack compiles to. Again, since we're using Babel for compilation, we just compile to "es2015" modules, as Babel will take care of compiling that to CommonJS that Webpack can understand.

  • allowJs: This is a weird one. This new option allows the compiler to, well, "compile" JavaScript. Without this, JavaScript files can't be imported from TypeScript files without creating a type definition for each file. This is basically required to be able to incrementally port a JavaScript application. It's a new option, though, and breaks a few things, which we'll see below.

  • allowSyntheticDefaultImports: This is the first "optional" setting in this config, but one I think you'll probably want.

tl;dr, by default, when you import a CommonJS package, TypeScript's module system will treat that module's export object as a list of bindings instead of a single default export, so you have to do:

import * as React from 'react';

If you enable this option, you'll be able to import things as you'd be used to in e.g. Babel:

import React from 'react';
  • sourceMap: Fairly self-explanatory, this will output source maps for each file. Babel seems to correctly consume these and update them so the final source maps are fine.

  • noEmit: We'll get into this in step five below, but this prevents any editor integration using this file from emitting (writing out) files, since obviously Webpack handles that. We'll override this in the Webpack config below.

  • outDir: Remember how I said allowJs breaks some things? outDir is usually supposed to specify a folder you build your files to, but we're actually using it here to work around this ridiculous ts-loader bug.

  • compileOnSave: This option signals editors that they shouldn't, uh, compile on save. Pretty simple.

TODO: Do you need compileOnSave if you have noEmit set? Does compileOnSave: false prevent type-checking?

  • exclude: We exclude node_modules from being compiled here. We also exclude one set of typed definitions from being included: typed has both a "main" and "browser" set of typings. Currently we're using the "browser" set in both because I'm lazy and haven't found any downside yet, but I suppose this could be overridden in webpack/server.js (below) if I wanted to use the main set in the server-side build.

Phew, and people say that Webpack is hard to configure. Speaking of which...

Step Three: Building the Client with Webpack

Thankfully, adding TypeScript to our client build code isn't too hard. Here's the major additions, working from the file above:

module.exports = {
  // ...

  resolve: {
    extensions: ['', '.jsx', '.js', '.tsx', '.ts'],
  },

  ts: {
    compilerOptions: {
      noEmit: false,
    },
  },

  module: {
    loaders: [
      {
        test: /\.tsx?$/,
        loaders: ['babel', 'ts']
      },

      {
        test: /\.js$/,
        exclude: /(node_modules\/)/,
        loader: 'babel-loader',
      },
    ]
  },

  // ...
};

We tell Webpack's bundle to allow import foo from './foo to look for foo.ts as well as foo.js, and override that noEmit option so that we actually emit JavaScript from Webpack. We then add a loader config that builds .ts files through TypeScript, then Babel. Easy!

At this point, we can run webpack-dev-server, and client code basically works. However, remember how I said this was a universal app? We have another part to worry about...

Step Four: Building the Server (!) with Webpack

So, manygolf isn't the first universal app I've built, but thus far, I've managed to avoid building server-side code through Webpack by using that babel runtime transform that I mentioned before. However, there is no simple TypeScript runtime transformer for Node, or if there is, I can't find it. Besides, runtime transformers are kind of a hack, and it's nice to be able to deploy "built" code and not have to install TypeScript/Babel on your server.

So, I added a special server.js Webpack config, using @jlongster's excellent guide. This was shockingly easy. Basically, you can use a code snippet to tell Webpack "don't actually bundle any of my node_modules imports", tell it you're targeting Node, add a little line of code to the top of the file to integrate source maps, and you're set.

The server-side webpack config is here. Now, to run the server, I just run:

webpack --config webpack/server.js && node bin/server

Step Five: Editor Config with Atom

So now Webpack is building the server and client code through TypeScript, awesome! There is one last step: editor configuration!

I chose to give Atom a shot, since it seemed to have a pretty mature TypeScript plugin. I've been a Vim user for the past few years, but I've never really learned to properly use a lot of its more "IDE-like" features, and I wanted something with a graphical interface that'd be a bit easier to learn. I'm sure Vim's TypeScript plugins are just as powerful, but I really didn't want to have to learn 8000 new key bindings and commands.

There's not a lot to say about setting up Atom - install atom-typescript, and things should pretty much Just Work.

Future Improvements

The most obvious future improvement would be to remove Babel from this. Sure, the TypeScript compiler is missing support for a bunch of ES7+ features that Babel supports, but, well, most of them wouldn't parse correctly in TypeScript in the first place (looking at you, object spread operator!), so there's not really any downside once everything is TypeScript.

There's a few annoying blockers on this, from a bit of investigating:

  • ts-loader doesn't accept a .js entrypoint yet (TypeStrong/ts-loader#163), so I'd have to make sure the entry points have been rewritten to be TypeScript
  • The es6 output in TypeScript not only ensures that the compiled output is ES6 syntax, it also enables ES6 type definitions for features like Object.assign(). I think there's a way to include these definitions in an es5-targeted project, but I'm not sure what it is.

There are also a couple of basic improvements I could make to the build process, like allowing the server to partially-rebuild and auto-restart when files change (see this guide for an example of this, though I don't plan to use Gulp).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment