TypeScript Monorepos

As TypeScript projects increasingly become the standard way of developing Frontend and Backend applications, the need for standardizing project setup has also increased. One way to achieve this is to adopt a monorepo in your organization. With TypeScript projects however, there are unique challenges that can impact your developer experience significantly.

There are many ways to make your TypeScript monorepo experience, so if you have any suggestions and would like to contribute them, we welcome pull requests to help improve things!

Project Structure

Let's look at strategies for how to best structure a TypeScript Monorepo

Path Aliases

Move from long path imports to path aliases for your initial step

Workspaces

Manage your monorepo using your package manager's workspace

Project References

How TypeScript can assist in optimizing your monorepo

# Project Structure

Starting off from collocated code and moving towards following best practices

Take your monorepo from just a collection of directories, to well defined modules using Path Aliases, Workspaces, and Project References.

By know how each of these concepts work in regards to you TypeScript monorepo, you can better organize your codebase and keep your builds fast.

How your code is shared across a monorepo

With each of these approaches, what we're really trying to solve is how the individual packages in your monorepo are made available to each other.

For instance, if you have app "A" and it is trying to import library "B", it can be cumbersome to have to write direct path imports to the library. As a project grows, this can be a challenge to manage, as well as negatively impacting your build times.

In addition to this, each approach can impact how TypeScript understands your project. From being able to find all the references for a symbol across your monorepo, to being able provide speedy code completion in your editor.

With each of these approaches, what we're really trying to solve is how the individual packages in your monorepo are made available to each other. If your monorepo is rather new or small and the relative connections across each package, you might feel confident with using direct imports (directly importing modules using relative paths), but as your monorepo eventually grows the need for well-defined modules will increase.

# Path Aliases

Path Aliases allow you to replace long import paths with a user supplied key for imports.

Mapping to a path

In the simplest terms, path aliases are a TypeScript construct that allow you to point to a directory somewhere in your codebase. When you import from @my-org/lib-a with path aliases, you actually aren't treating that package as a module.

Path aliases tell TypeScript to not treat the import statement as a module to be resolved, but rather use the key of '@my-org/lib-a' as a reference to where the module is located. However, if you build and try to run the compile output, you will get an error as the TypeScript compiler doesn't actually replace the import path with the correct path. For this, you'd need an extra tool or build step to find and replace the aliased path with the correct relative path.

Path Aliases also don't actually change how our monorepo is structured or if we have well defined boundaries, they just address to visual of long import statements. Our code, while collocated, is not isolated in any meaningful way. With this in mind, the use of Path Aliases should be considered a step towards something better.

In fact, the TypeScript team even stated that developers should avoid using path aliases all together. If you are wanting to go full in on monorepos, there are better solutions that can be found in your package managers.

# Workspaces

Workspaces are a mechanism to properly connect the packages in your monorepo as modules.

At a glance

Workspaces are a built in feature of most JavaScript packages managers that allow you to tell the package manager a certain directory contains subprojects. Package managers like npm, yarn, and pnpm all provide a way to setup a workspace.

With workspaces, when your packages manager reads the package.json, it will take any directory in that workspaces and link it to your root node_modules. When your project is being build, the build tools can treat things as if it was just another packages installed from a package registry. The benefit here is that there is no additional overhead that path aliases would introduce.

Workspaces however, do require an extra step before the packages can be used in your monorepo. Since most packages require some sort of build step before being able to consume the code, the monorepo tool you choose will have a big impact here. For instance, with Nx, you can have the build step of dependent projects be performed before you start up the main process. Alternatively, you'd have to build the packages manually before starting any other process. Check with your monorepo tool to see if they support automatic builds of dependencies.

Direct Exports vs Pre-built Code

With workspaces, we have two options for making our code accessible through out our codebase. We could prebuild our code or we could directly export the TypeScript source. .

Direct Export

By directly exporting, this means to take your TypeScript source and set it as your exports in a package.json

If the modules in your monorepo are for internal use and will never be published to a package registry, this is valid option.

Prebuild

With prebuilding, you're running the build tasks needed for any module in the monorepo ahead of time. Your export the compiled and generated code, and provide the generated types.

The benefit here is that everything can be done ahead of time, so you only have one task being run. However, if you need to change of the modules being consumed, you need to rerun the build for the affected module. Some monorepo tools, like Nx provide a way to do this, but other tools might not.

Either option can work, the major factor would be if you are publishing the libraries for others to use outside of your monorpeo.

# Project References

Provide deeper understanding of your monorepos types and speed up TypeScript compilation for large projects.

References Across Monorepos

With the move to workspaces, one thing that gets left behind is the detailed type information that our projects can provide. Path Aliases also have this issue, as deeply nested imports might not always get the correct type information from TypeScript. We can help TypeScript out here by utilizing Project References.

Project References are references to nested tsconfig.jsons that are in a workspace. By providing them, you can inform the TypeScript compiler about any nested projects and the types present in there. Now TypeScript will be able to treat each project as it's own isolated piece of code, and can better optimize how it returns any type information for your editor. In addition to the type benefits, TypeScript can now compile pieces of your codebase in better isolation. In the loosest sense, project references turns the TypeScript compiler into a monorepo tool.

Project references can have some draw back in monorepos that include a lot of types. For instance, trcp can generate type information for every route in your API. Normally this is great, and project references can make sure that type information is available to you. But if you have a large API, you could be dealing with significant delays in your editor when you try to trigger auto-completion or get a symbols type. This isn't only isolated to trpc, but any monorepo that has a large and complex type setup.

# Performance Benchmarks

Understand the performance impact of how you setup your TypeScript monorepo

Shipping code faster

How you structure your TypeScript monorepo impacts more than your developer experience, it can also impact how fast changes in your repo are built and released to the world. With package linking and project references, the overall build time for your monorepo can be significantly faster. With faster CI times, you can get things shipped faster all while having a better developer experience.

In the following example, we compare the build times for a TypeScript monorepo using path aliases with a TypeScript monorepo using project references and workspaces. This example uses Nx, but other monorepo tools may have similar results.

Path Aliases

186.53s cold

References + Workspace

175.52s cold
25.33s hot

References + Workspace (incremental updates)

36.33s 1 package
48.21s 5 packages
65.25s 25 packages
80.69s 100 packages

What's worth pointing out here is the difference in time when dealing with incremental updates. With Path Aliases, TypeScript need to perform full rebuild of every package. However, with project references in place, TypeScript can understand what packages have changed and skip a rebuild if possible, reducing the time needed to rebuild.

# Resources

Here is a curated list of useful videos and podcasts to go deeper or just see the information in another way.