I’ve been experimenting with ways to structure a complex TypeScript project lately while working on Vaultage and I have finally found a solution that provides proper isolation and consistency across packages.
Java developers should be familiar with having dozens of sub-projects open simultaneously in their IDE, each with its own build target and dependencies.
In comparison, JavaScript projects tend to be a mess because of the lack of an idiomatic way to split code across packages. Tools like lerna help you create a proper JavaScript monorepo and rationalize the development of a complex project. However these tools pack a lot of features which take some time to properly master. The added complexity may result in misunderstanding and in the end the time gained by deploying the tool may be lost in obscure debugging sessions. Additionally, TypeScript is a different beast and requires more work to integrate properly.
In this tutorial, you will learn the basic principles behind a multi-package TypeScript project so you can apply them in your own work.
I created a minimalist project skeleton so you can follow along this tutorial. You can download it here.
The setup
Download and unpack the tutorial files into your workspace. You should end up with a project containing a packages
sub-folder with three packages inside.
Each package is an independent unit of code, with its own build target and test suite:
- The
tstuto-server
package contains the NodeJS server files - The
tstuto-web-client
package contains the web application which talks to the server - The
tstuto-api
package contains shared definitions which the client and server will use to communicate in a type-safe way.
As you may have guessed, our example application consists of a simple web server and a web application which talk over HTTP.
Go ahead and open the top-level folder in your favorite TypeScript IDE (it should be VSCode, if it’s not, then go ahead and download it now, I’ll wait…).
First, you’ll want to check that everything works as intended. Navigate to packages/tstuto-web-client
and run:
1 | npm install |
Then repeat this step for the tstuto-server
package.
When you are done, you should have successfully built the demo application. Navigate to the tstuto-server
package and run npm start
to launch the server. Then, point your web browser at http://localhost:3000
. You should see an ugly web page with a button.
Using a shared package
Take a look at the files packages/tstuto-server/src/controllers/MoodController.ts
, and packages/tstuto-web-client/src/main-client.ts
.
The client uses the axios library to fetch a mood from the server over HTTP. If you are a type safety freak, something should tickle your senses here: the communication is not safe. Indeed, look at the type returned by the axios
call in main-client.ts
: you’ll find that it is of the any
type. You can use this object however you want and the TypeScript compiler will never complain, even though your code might crash at run-time!
Enter the tstuto-api
package. Take a look at packages/tstuto-api/src/index.ts
, we define a type and two factory functions there. Compile them by navigating to packages/tstuto-api
and running npm install && npm run build
.
Now, going back to MoodController.ts
, replace the function by the following, which uses the factory methods instead of the inline object definitions:
1 | import { happyMood, sadMood } from '../../../tstuto-api/src/index'; |
In main-client.ts
, use the interface to type the object returned by axios:
1 | import { IMoodAPIResponse } from '../../tstuto-api/src/index'; |
That is way better, our API is now type-safe. The TypeScript compiler catches typos, and we have proper auto-completion and code refactoring!
However, written as is, our import statement actually instructs the TypeScript compiler to compile the target module along with ours. This has multiple nasty side effects and defeats the point of having separate modules altogether.
It would be much cleaner if we could just write:
1 | import { IMoodAPIResponse } from 'tstuto-api'; |
We will see how we can achieve this in the next section.
Proper code sharing
So far, we’ve split our code into three modules and used import statements to borrow code from one module into another. However, we would like to isolate the modules further and import the built artifact rather than importing the raw source code. This means that we want the TypeScript compiler to use the type definitions emitted during compilation and we want node (or webpack for the client) to import the generated JavaScript file rather than the source TypeScript. This helps us avoid bugs spreading across modules and prevents careless developers from producing spaghetti code (to some extent…). It will also speed up your builds because tsc
won’t have to compile the same source files over and over.
Go ahead and replace the two imports looking like import xxx from '../../api/src/index'
in main-client.ts
and MoodController.ts
with just import xxx from 'api'
:
1 | // main-client.ts: |
Don’t worry about the compiler error. All we need to do to make it disappear is instruct the TypeScript compiler to look for our custom packages in the packages
folder. Fortunately, there is an option called baseUrl
which allows us to do just that.
Edit tsconfig.json
at the root of the project and uncomment the line "baseUrl": "packages"
. This way the TypeScript compiler also looks at the packages
directory to resolve package names.
Note 1: Your packages may conflict with packages installed in
node_modules
; this is why we prefixed all our packages withtstuto-
: to make sure that we don’t accidentally shadow an actual npm package.
Note 2: You may need to reload your editor after you changed
tsconfig.json
. In VSCode, open the command palette (CTRL+SHIFT+P) and chose “reload window”.
There is one more thing we need to do in order for this to work. TypeScript will not recognize your custom module unless you specified the type
property in its package.json
. Open packages/tstuto-api/package.json
. You will see a line with the text “TODO”. Replace it with the following:
1 | "types": "dist/index.d.ts" |
You want to make extra sure that you got this setting right. If there is an error here, nobody will let you know, your imports might resolve to any
and you won’t notice your mistake until it’s too late. The types
property in package.json must point to the type definition of your entry point!
Now if you go back to packages/tstuto-server
and run npm run build
, you should get a successful build. However, if you try to start the server with npm run start
, it will fail. Why? Although the TS compiler has figured out your project structure, Node.js is still oblivious to it: it doesn’t know where to find your custom modules at run time!
1 | $ npm start |
The next trick we will use is called NODE_PATH
.
NODE_PATH
is an environment variable node uses for pretty much the same purpose TypeScript uses “baseUrl”: it looks for additional node_modules
inside the directory specified by NODE_PATH
. The problem is that hacks based on environment variables tend to work poorly cross-platform. That’s why we will use cross-env
, a nifty node module that lets you define environment variables in a portable way.
In packages/tstuto-server/package.json
, replace the line "start": "node bin/server.js",
with "start": "cross-env NODE_PATH=.. node bin/server.js",
.
Now, when you run npm start
, node will also look at the parent directory when resolving modules. This is how it will know that tstuto-api
refers to your custom module in packages/tstuto-api
.
Your application should now work properly!
Next steps
You have learned the fundamental tricks which will allow you to structure a multi-package typescript project. There are things left to fix though.
- It is tedious to go into each sub-project and manually run
npm run build
each time we change something. In addition, if we add more modules, manually tracking dependencies can quickly become a nightmare. That’s why we need a build and dependency tracking system to handle all of that for us. - We would like to export our project, either to be distributed as an npm module or to be deployed somewhere. We can not ship our packages as separate npm packages just like that because they now depend on the directory structure of the repository.
See you in the next part of this tutorial, where we discuss those issues.
Appendix: How did you serve the client files again?
You may have noticed that our server also takes care to serve the client (as static files). While there are scenarios where you will want to ship the client separately, serving it from the API server is quite handy for development and suits a broad range of practical use-cases.
The trick fits into these three lines of code:
1 | // Bind static content to server |
We get the absolute path to the tstuto-web-client
module and concatenate the public
directory to it; we then instruct express to serve this folder as static content. Doing it this way allows us to keep the server and client completely separated and avoid any copy which would make our build system much more complex.
You should use the module name 'tstuto-web-client'
instead of the relative path '../../../tstuto-web-client'
now that you have learned the NODE_PATH trick. The reason the tutorial files ship with the relative path is to make it work as-is (even if you don’t set NODE_PATH), but this will break when you’ll try to deploy the app in the next part.
Thanks Ludovic for proofreading this tutorial.