Best Practices for TypeScript Monorepo

Best Practices for TypeScript Monorepo

Table of contents

No heading

No headings in the article.

Managing dependencies of multiple projects across multiple repositories can be time-consuming and error-prone. A monorepo, on the other hand, is a code management or architectural concept that consolidates all the isolated codebases of multiple projects into a single mega repository rather than managing them individually. Monorepos can be advantageous when used with the proper tools. Hence, many organizations have adopted the strategy of maintaining several projects in a single repository.

Large corporations such as Google, Meta, and Microsoft often manage the codebases of multiple projects within the organization in a single monorepo. This approach enables them to share dependencies, libraries, components, utilities, docs, e.t.c. between projects. Sharing code between projects ensures uniformity and predictability in the codebase. However, dependency management is where the real power lies. In the event that somebody makes a breaking change to a shared library, all affected applications will receive that update immediately.

Although monorepos do come with their own set of challenges, they also offer a number of benefits with the right tool. One of those tools is Typescript, and in this post, we will be looking at best practices for managing a Typescript monorepo.

Using TypeScript Project References The main goal of the development of TypeScript project references was always to assist solve the issue of long compilation times in big TypeScript projects such as a monorepo. They make it possible to divide a huge project into several smaller modules that may all be independently built. Additionally, it enables the creation of more modular code. With this approach, build times can be greatly improved, components can be logically separated, and your code can be reorganized in a more organized and logical manner.

Visit the Docs to learn more about TypeScropt Project Reference.

Managing Packages that Depend on Other Packages When working on multiple packages that depend on another package in a Typescript monorepo, you have to explicitly let Typescript know of this dependency. For example, @projectName/package-A depends on @projectName/package-B. We need to add the following steps to let Typescrip know about this dependency.

First, you have to add this in the tsconfig of the first package.

// packages/package-b/tsconfig.json { "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", "composite": true }, "include": ["./src"] }

The next step is to reference the package in package-a.

{ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", } }, "include": ["/*.ts", "/.tsx"], "exclude": ["dist/"], "references": [{ "path": "../package-b/tsconfig.json" }] }

Set Up Workspaces Workspaces are a concept found in yarn, NPM, and other tooling that can be used to give packages and apps in a monorepo repository their own workspace in the form of an organized and consistent folder structure. Large projects such as monorepos can benefit from workspaces for managing packages and dependencies.

In Yarn workspaces, it is possible to create projects such as:

packages/
localPackageA/
package.json
...
localPackageB/
package.json
... With Yarn Workspaces, installing packages across workspaces becomes faster and lighter. Additionally, it prevents package duplication across workspaces and also makes it possible to create links between directories that depend on each other, ensuring that all directories are consistent in the monorepo.

You can choose between yarn workspaces, npm workspaces, or pnpm workspaces.

Use Absolute Paths for Importing Code Blocks It is best practice to use absolute paths when importing code rather than long relative paths. This is important for code clarity because, as the codebase becomes larger, there will be more deeply nested folders and files, and importing them using their relative paths is one of the quickest ways to make a mess in a codebase, as this will make the code messy and unreadable.

Let's take a look at the example below.

import { FormType } from '../../../../types/form'; import { DateType } from '../../../../types/date';

// This is considered bad practice

Looking at the code above, any developer assigned to work on this file will find it hard to know exactly which folder these code blocks—in this case, Types—were picked and imported from. So it is best to use an absolute path. Let's see a better example of the above code below:

import { FormType } from 'utils/types/form'; import { DateType } from 'utils/types/date';

// This is considered a good practice

From the code above, the import is now clear, readable, and predictable, as the developers are aware of the exact folder where the piece of code being imported is coming from.

Using the absolute import method may be stressful as it involves more writing as opposed to adding dots and slashes till you get to your destination. However, it serves well in the long run when the codebase becomes larger with more folders and files.

There are tools that can help you achieve this; tools that will add a new resolver for your modules when your code is being compiled. One of those tools is the Babel Plugin Module Resolver, which you can read more about here: Babel Plugin Module Resolver.

Prettier and ESLint A large codebase with multiple projects that have multiple people working on them daily over a long period of time has the tendency to be inconsistent in coding style. Catching common errors during development should be considered a core part of a developer's work, silly and unintentional mistakes that might have passed the compiler test would get caught in the net of a linter such as Eslint, wrong file imports, unused variables e.tc to mention just a few, can be avoided if Eslint is used.

The use of Prettier and ESLint in a monorepo generally works well. With Prettier, you can maintain consistency in your formatting across all of your projects. Simply create a .prettierrc configuration file in the monorepo's root directory with your desired configuration settings and it will be applied to all packages in the monorepo automatically.

With ESLint, JavaScript or TypeScript source code can be analyzed in a sophisticated manner. As with Prettier, it can be configured as easy for monorepos. You simply define a .eslintrc.json configuration file in the main repository of the monorepo and it will apply to all projects.

However, if there are many files in the monorepo, Prettier or ESLint may take a very long time to run. This can be resolved by adding script definitions to a local package's package.json that reference the Prettier and ESLint configuration in the root of the project, so that Prettier and ESLint only run for specific packages.

Using Turborepo There are several amazing tools to aid monorepos and ship with a smooth developer experience. One of those tools is Turborepo, which is a powerful tool that aids in the development of high-quality and performant build systems in JavaScript and Typescript monorepos. It comes with advanced features, one of them being parallel execution.

When executing npm run dev or yarn dev from the root folder, it starts all of the projects available in the monorepo that have a dev script in their package.json file. The same thing applies when other commands such as npm run build, npm run lint, npm run start. In Turborepo, you can achieve this by configuring the package.json file top-level folder:

"scripts": { "dev": "turbo run dev", "lint": "turbo run lint", "build": "turbo run build", "clean": "turbo run clean", ... }, "devDependencies": { ... "turbo": "latest" }

Turborepo also comes with various tools and many other configurations that allow you to execute scripts in deeply nested workspaces in parallel by default or you can choose to do them by order or filter them.

"scripts": { "dev": "turbo run dev --filter=\"docs\"", ... },

Turborepo comes with an advanced remote caching feature, high-performant builds for files locally which is a default feature, and also for files remotely. You can choose to opt out of local caching at any time.

Furthermore, you can also create a monorepo pipeline for the execution of your scripts using Turborepo. You can check out Turborepo's documentation to learn more. There are other alternative tools like Lerna and Nx which you can use to achieve the same result.

The Right Build Tools Choosing the right build tools for the deployments of your monorepo is a very important decision and should be done carefully because, without efficient bundling, we can run into the issue of having to deploy all the code in the repository even if deployments should compose of only necessary source files.

Just as we used Jest, we can also employ the use of Webpack for bundling in a monorepo that can be configured to use Typescript references. This can be achieved by simply using ts-loader and everything is set to work automatically.

There are also more tools to use, such as esbuild. Esbuild ships with TypeScript support by default and because of this it automatically resolves any local references because we already have TypeScript project references configured. You can also add additional configuration using the @yarnpkg plugin, this helps Esbuild resolve external dependencies from the local Yarn cache.

Changesets is also a popular versioning tool used for managing multiple packages in a repository such as monorepo, it provides maintainers a workflow that helps automate updating of package versions and publishing of new packages.

Conclusion Using Typescript in a monorepo may require some configurations and the use of some best practices. You will, however, be able to increase the maintainability of your codebase and provide uniformity and visibility across your company's entire code base without the need to track down different repos.