Tuesday, 21 August 2018

💣ing Relative Paths with TypeScript and webpack

I write a lot of TypeScript. Because I like modularity, I split up my codebases into discreet modules and import from them as necessary.

Take a look at this import:

import * as utils from '../../../../../../../shared/utils';

Now take a look at this import:

import * as utils from 'shared/utils';

Which do you prefer? If the answer was "the first" then read no further. You have all you need, go forth and be happy. If the answer was "the second" then stick around; I can help!

TypeScript

There's been a solution for this in TypeScript-land for some time. You can read the detail in the "path mapping" docs here.

Let's take a slightly simpler example; we have a folder structure that looks like this:

projectRoot 
├── components 
│ └── page.tsx (imports '../shared/utils') 
├── shared 
│ ├── folder1 
│ └── folder2 
│ └── utils.ts 
└── tsconfig.json

We would like page.tsx to import 'shared/utils' instead of '../shared/utils'. We can, if we augment our tsconfig.json with the following properties:

{ 
  "compilerOptions": { 
    "baseUrl": ".", 
    "paths": { 
       "components/*": ["components/*"],
       "shared/*": ["shared/*"]
    }
  }
}

Then we can use option 2. We can happily write:

import * as utils from 'shared/utils';

My code compiles, yay.... Ship it!

Let's not get over-excited. Actually, we're only part-way there; you can compile this code with the TypeScript compiler.... But is that enough?

I bundle my TypeScript with ts-loader and webpack. If I try and use my new exciting import statement above with my build system then disappointment is in my future. webpack will be all like "import whuuuuuuuut?"

You see, webpack doesn't know what we told the TypeScript compiler in the tsconfig.json. Why would it? It was our little secret.

webpack resolve.alias to the rescue!

This same functionality has existed in webpack for a long time; actually much longer than it has existed in TypeScript. It's the resolve.alias functionality.

So, looking at that I should be able to augment my webpack.config.js like so:

module.exports = {
  //...
  resolve: {
    alias: {
      components: path.resolve(process.cwd(), 'components/'),
      shared: path.resolve(process.cwd(), 'shared/'),
    }
  }
};

And now both webpack and TypeScript are up to speed with how to resolve modules.

DRY with the tsconfig-paths-webpack-plugin

When I look at the tsconfig.json and the webpack.config.js something occurs to me: I don't like to repeat myself. As well as that, I don't like to repeat myself. It's so... Repetitive.

The declarations you make in the tsconfig.json are re-stated in the webpack.config.js. Who wants to maintain two sets of code where one would do? Not me.

Fortunately, you don't have to. There's the tsconfig-paths-webpack-plugin for webpack which will do the job for you. You can replace your verbose resolve.alias with this:

module.exports = {
  //...
  resolve: {
    plugins: [new TsconfigPathsPlugin({ /*configFile: "./path/to/tsconfig.json" */ })]
  }
};

This does the hard graft of reading your tsconfig.json and translating path mappings into webpack aliases. From this point forward, you need only edit the tsconfig.json and everything else will just work.

Thanks to Jonas Kello, author of the plugin; it's tremendous! Thanks also to Sean Larkin and Stanislav Panferov (of awesome-typescript-loader) who together worked on the original plugin that I understand the tsconfig-paths-webpack-plugin is based on. Great work!