Making TypeScript monorepos play nice with other tools
In my previous blog post, How to set up a TypeScript monorepo with Lerna, I covered setting up a simple TypeScript monorepo and focused on making code navigation in an IDE and publishing packages work without any surprises. The solution revolved around path aliases that let us map package names to their location inside the monorepo.
In this follow up post I’ll cover some of the most common tools and frameworks that need some extra config to work with monorepos that use path aliases:
Fully working examples for each of the above can be found in the following GitHub repo:
Let’s dig in.
ts-node
ts-node is a drop-in replacement for node
which compiles your TypeScript files on-the-fly as you import them. I recommend using it over node
in your TypeScript projects, especially since making monorepos work with it is easier than making them work with node
. The switch is as simple as replacing node dist/index.js
with ts-node src/index.ts
.
If you try running ts-node
on a monorepo that uses path aliases then you will most likely encounter the following error:
> ts-node src/index.tsError: Cannot find module '@nighttrax/foo'
This is because the TypeScript compiler doesn’t actually rewrite imports according to the paths
defined in tsconfig.json
. The aliases are only used by the type checker and the import statements are left untouched in the output.
Fret not, as there’s a very simple way to make the aliases work at runtime — the tsconfig-paths
package. It provides a hook for Node’s require
function that intercepts your imports, checks them against the aliases you defined in tsconfig.json
, and rewrites them accordingly at runtime.
You can see the full example in the repo here.
Babel
If you use Babel with @babel/preset-typescript
then you can simply add babel-plugin-module-resolver
to the plugins list and specify the path aliases in your babel.config.js
:
You can specify each path alias individually or, if you use a scope for your packages, you can use a RegExp to match them all.
You can see the full example in the repo here.
Webpack
If you use babel-loader
then you can copy the solution above from the Babel section using babel-plugin-module-resolver
.
Alternatively, you can use webpack’s built-in support for aliases and define the path aliases in a webpack.config.js
using the resolve.alias
section:
To avoid duplication we can use tsconfig-paths-webpack-plugin to automatically apply all the aliases from tsconfig.json
:
You can see the full example in the repo here.
Jest
If you use Jest with Babel then you can apply the same solution from the Babel section above using babel-plugin-module-resolver
.
If you use ts-jest instead (and you should because it enables type checking) then you need to define the aliases using moduleNameMapper
in your jest.config.js
:
You can use a RegExp to match all of your aliases, or you can list them individually. To avoid duplicating the paths from tsconfig.json
you can use the pathsToModuleNameMapper
helper to automatically apply all of them:
You can see the full example in the repo here.
create-react-app
create-react-app does not natively support path aliases so we’re going to have to do a bit of work for this one.
You can either eject the CRA config and apply the following changes to the ejected config/webpack.config.js
, or you can use the react-app-rewired package to extend CRA’s config without ejecting.
Here’s what we’re going to do:
- We’ll use the same tsconfig-paths-webpack-plugin package to get webpack to resolve our path aliases.
- We’re going to change CRA’s webpack config to allow compiling files outside of the
src/
folder.
I’ll be using react-app-rewired
to apply the changes and you can follow its simple instructions to get things started. In the root config-overrides.js
I’ll add the tsconfig-paths-webpack-plugin
to resolve the path aliases:
If you try to run npm start
again, you’ll be greeted with the following error:
You attempted to import packages/foo/src which falls outside of the project src/ directory. Relative imports outside of src/ are not supported.
You can either move it inside src/, or add a symlink to it from project's node_modules/.
This is where the second part starts: CRA is set up to only compile the src/
folder and uses a plugin called ModuleScopePlugin
to throw an error if you try to import anything outside of that folder. The path aliases will resolve to files inside the monorepo that are outside of the app so we need to remove the compilation restriction and this plugin. We can do the latter easily by just popping from config.resolve.plugins
:
If we rerun the start
script now we’ll see a different error:
../components/src/button.tsx 3:28
Module parse failed: Unexpected token (3:28)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| import React from "react";
|
> export const Button = () => <button onClick={() => alert('you clicked me')}>Click me</button>;
This confirms that the path aliases are resolved correctly and that CRA is not preventing the imports anymore, but webpack is still set up to not compile files outside src/
. CRA sets the include
directive of the Babel loader to only the app’s src/
folder, causing it to only compile the files in there and nothing else.
We can either adjust the include
directive to include the other monorepo packages as well, or remove it completely. If we go with the latter we have to add an exclude
directive to tell webpack to avoid compiling node_modules
, otherwise performance will suffer greatly.
I went with the include
/exclude
combo as I think it’s more maintainable and, unless you’re doing something weird, shouldn’t cause any issues.
Now everything should be working. You can see the full example in the repo here.
NextJS
NextJS supports path aliases out of the box, though you need a little bit of extra config to tell NextJS to compile your dependencies, similar to the setup for webpack
and create-react-app
:
You can see the full example in the repo here.
Note: If you’re using at least version 10.1.0
you can toggle an experimental flag in your config to avoid manually tweaking webpack:
NestJS
There a couple of options here, and both involve keeping your path aliases in both the tsconfig.json
file and the tsconfig.build.json
one (you can use extends
to avoid duplicating them).
The first solution is to use webpack
(which will automatically use the tsconfig-paths
plugin) by updating the nest-cli.json
file:
If you don’t want to use webpack
, or you’re running into problems with it finding NestJS core packages (see issue #61 in the GitHub project), then you can keep using tsc
and make a small adjustment to the nest-cli.json
:
Above you can see the entryFile
option which points NestJS to the location of the compiled main.js
file. Since we’re using tsc
with path aliases, the build output will contain the parent folder structure for all of our dependencies, including the NestJS project.
examples/nestjs/dist
├── examples
│ └── nestjs
│ └── src
│ └── main.js
├── packages
│ └── foo
│ └── src
│ └── index.js
This is because tsc
will match the output to the path aliases inside the monorepo. Knowing this, we can just tell NestJS to load the main.js
file from the right path.
You can see the full example in the repo here.
Thanks for reading and I hope you found this article helpful. You can find full examples of all of the above in the following repo:
If you enjoyed this article then maybe you’ll enjoy reading my other articles or checking out some of my other open source projects: