Typescript: Working with Paths, Packages and Yarn Workspaces

How to modularise your Typescript projects with paths, packages and Monorepos

Ross Bulat
Jun 13 · 12 min read

Understanding packages is understanding modularity

This article explores critical tools and concepts a developer can leverage as a means of managing your codebase to promote modularity and reusability, and overall aid in project management. In particular, we will cover:

  • Typescript paths aliases and how they simplify your project structure and import statements. We’ll also cover the fallbacks of using paths in terms of their support in major frameworks today
  • Alternatives to using paths with your private package registry, and where this would be suited. We will cover the process of configuring a Typescript package, and deploying it to an NPM registry.
  • Multi Package Projects / Monorepos: We’ll cover how a monorepo project is set up in Typescript and the benefits they bring. A monorepo is a project hosted as a repository, that contains a multitude of separate packages that make the complete project. For example, you may have a @myapp/ui as a package to handle the app’s common UI elements, @myapp/types to define all the types used, and @myapp/styles to define the CSS / styled components used throughout your monorepo. This is a powerful concept for a few reasons — they will be covered further down
  • Yarn workspaces: Yarn workspaces provide a built-in means of configuring a monorepo. We’ll cover how to set up a Typescript-based monorepo, and introduce a dedicated tool, Lerna, for multi-package projects

With these concepts under your belt, you can introduce them to your existing and future projects where they make sense — monorepos for example are great for larger projects, but perhaps not as suited for a small-scale personal blogs. Path aliases on the other hand could work very well with smaller projects, and also utilise in-house packages for re-usable components.

Using Path Aliases

Paths bring two big benefits:

  • They provide simplified paths to directories, allowing them to act as shortcuts to packages and other resources used throughout your project
  • They vastly decrease the use of relative import statements and the amount of ../'s, a welcome optimisation of projects with deep nesting.

Instead of importing a module from somewhere like a top-level utils folder, we can instead define a path, called @utils, and use it in my import statements:

// using a path
import { func } from '@utils/common';
// not using paths
import { func } from '../../../../utils/common'

Import statements now become a lot easier to read and manage. Defining paths is done under a paths block inside tsconfig.json:

...
"baseUrl": "./src",
"paths": {
"@utils/*": [
"packages/utils/*"
],
...
}

Notice that our @utils path can point to a number of directories; we provide an array of paths that the module could be sitting in. Also note that a baseUrl is defined as the project’s src/ folder. This is the relative location to which paths originate.

With this in mind, our project structure may resemble the following:

dist/
package.json
src/
packages/
utils/
common.js
...
components/
index.js
...
tsconfig.json

The baseUrl within tsconfig.json is pointing to the src/ folder, which houses a packages/ directory with our util scripts therein.

Note: Paths can lead to anywhere under the baseUrl; you are not limited to a packages/ directory, although a packages/ directory is a common convention, especially if projects are (or plan) to move to a multi-package setup.

Path Alias Support

NodeJS environments are a great use-case for path aliases today, whereas frameworks like Create React App is still working to support them. Let’s briefly explore compatibility with popular environments.

Note: In the context of front-end apps, you also have the option of configuring a project from scratch to support path aliases, React or otherwise, but that is out of the scope of this article.

Setting up path support for Node

To set this up, simply install the package with yarn:

yarn add module-alias

Within package.json, add a _moduleAliases block. This block closely resembles the tsconfig paths block:

"_moduleAliases": {
"@utils": "dist/packages/utils"
}

Notice that dist/ is included in the path location; this is the folder where the compiled Javascript is located. Finally, import a register function to your top level file, such as app.js if you’re using Express boilerplate for example:

import 'module-alias/register';

This is all that is needed to support paths in Node projects.

Note: module-alias is designed to work with final projects such as a web server or application, not with packages designed to be dependencies.

Angular support

{
"compilerOptions": {
...
"baseUrl": "src",
"paths": {
"@utils/*": ["src/packages/utils/*"],
"@environments/*": ["environments/*"],
...
}
}
...
}

Create React App support

Create React App 3.0 (Github release) now natively supports absolute imports with the baseUrl setting inside tsconfig.json. However, paths are still not supported at the time of writing, and are removed from tsconfig.json at runtime if any are defined. Support is being worked on, the progress of which is still an open issue.

Note: There is a somewhat verbose workaround for enabling paths for CRA described here.

Nevertheless, you may wish to upgrade to CRA3.0 if you have not done so already, and prepare your projects for the upcoming support of paths. If you are running a previous version of the package, firstly upgrade the global command-line utility, then update your projects react-scripts package and its dependencies:

# re-install Create React App
npm uninstall -g create-react-app
npm install -g create-react-app
# update existing project react-scripts
yarn add --exact react-scripts@latest
# upgrade your dependencies to ensure compatibility
yarn upgrade

Note: CRA version 3 does have breaking changes from the previous version. Most notably, the rules of hooks are now enforced, Typescript is now linted, and Jest 24 is now used. Check out all breaking changes here before upgrading.

Alternatives to Path Aliases

I have written an article to do exactly that, which can be found here:

Like path aliases, packages have the benefit of absolute import statements. There are a number of opportunities where packages can be utilised to tidy up your project, and limit code-repetition in other projects as a result, including:

  • Common UX components that could be generic
  • Styling boilerplate such as page layout, text styling and default styles
  • Common form validations that may be specific to your needs
  • API handlers and other wrappers to handle external services
  • Any emerging design patterns in your app that can be templated

Let’s next explore how we can package up a Typescript module and deploy it to an NPM registry.

Publishing Typescript Packages to NPM

Initiate package project

mkdir my-ts-package
cd my-ts-package
# generate package.json
npm init -y
# generate tsconfig.json
tsc --init
# initiate git
git init

Note: Your output project folder, dist/ in this case, should be ignored in .gitignore.

Initiating Git is optional, but is recommended practice to leverage the benefits of managing and updating the package’s code.

Configure tsconfig.json and package.json

// tsconfig.json{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"lib": [
"dom",
"dom-iterable",
"esnext"
],
"declaration": true,
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"exclude": [
"node_modules",
"dist"
]

}

The declaration property declares that we wish to generate a .d.ts file when the project is compiled. In addition, make sure outDir is defined; in the above case the compiled Javascript will be saved in the dist/ folder.

Also, within package.json, a types property needs to be included, defining the location of the package’s definitions file that the Typescript compiler refers to when our package is imported into another project. This value is usually the same name as your package’s main entry point, in this case, index:

// package.json{
"name": "my-ts-package",
"version": "1.0.0",
"description": "My Typescript NPM Package",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc"
},
"author": "Ross Bulat",
"license": "MIT",
"devDependencies": {
"typescript": "^3.5.0"
}
}

Create package exports

Note: Make sure the types defined in your project are exportable to give apps using the package access. Prepend export to any that are not.

# build package
yarn build

Publish package

yarn publish --registry 'http://your-registry'

You are now able to install and add the package as a dependency to your projects:

# install package
yarn add my-ts-package --registry 'http://your-registry'
# verify package
yarn info my-ts-package

Now simply use your package’s exports as you would any other:

// somefile.tsximport { MyFunc } from 'my-ts-package';

With that, we have now walked through the process of setting up and publishing Typescript packages, giving you another tool for simplifying your codebase and project structure, while cleaning up your import statements in the process.

There are some scenarios however where separating your app functionalities via packages won’t make sense — that brings us to the multi-package / monorepo project structure, a more suited setup for larger projects where compartmentalising functionalities, components or sections of the app can aid in project upkeep and efficiency amongst teams of developers.

Multi-package projects

These packages will need to be housed within a main project repository; a repository that brings them all together.

What we end up with is something like the following:

main project repo
packages
apps
app-dashboard
app-landing
app-support
ui-components
ui-styles
ui-types
^
each package has its own package.json and tsconfig.json

In this example, the apps package will embed the three sub-sections that will comprise the entire app, with each of those utilising the components from the ui- packages. Each package is dependent on another package.

This type of project is called a Monorepo. Let’s see how these can be configured, with Yarn Workspaces.

Working with Yarn Workspaces

Advantages of using yarn workspaces

Note: Where package specific dependencies are required, e.g. if one package requires a different version of a dependency than another package, then it will be saved at that package level.

To enable yarn workspaces, add a workspaces configuration in the root folder’s package.json, and ensure your package is set to private:

{
"private": true,
"workspaces": [
"packages/*"
],
...
}

As a security precaution, workspaces must be private. The workspaces property itself takes an array of directories, and supports the wildcard. In this case we have defined every folder under the packages/ directory to be a workspace.

Multiple package structure

apps/
.gitignore
package.json
README.md
tsconfig.json
packages/
app/

package.json
tsconfig.json
src/
ui-app/
package.json
tsconfig.json
src/
ui-styles/
package.json
tsconfig.json
src/
...

Let’s break down this setup:

  • The root apps/ folder houses all the packages under its packages/ folder, and contains the top level configuration of the project via the package.json and tsconfig.json files. Your .gitignore and Readme files can also live at this top level, but it is encouraged to write a separate Readme for each package too.
  • Each package is housed in a containing folder, which are commonly named identically to the name field in their package.json. In our case, the app package is loaded to run the app.

Note: In the case a package itself initiates your app, you can amend your start script in the root’s package.json file to run that particular app:

...
"scripts": {
"start": "cd packages/app && yarn start",
}

This allows you to run yarn start in your root directory while running the correct package to start the app.

  • Each package has its own tsconfig.json, which extends the root package’s tsconfig.json to save repetition and keep consistency. However, each package needs its own rootDir and outDir values. The following resembles a valid configuration file:
// packages/app/tsconfig.json{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "es5",
"module": "esnext",
"moduleResolution": "node",
"rootDir": "src",
"outDir": "dist",

"declaration": true
}
}

Depending on a workspace

// packages/app/package.json{
...
"dependencies": {
"ui-app": "1.0.0",
"ui-styles": 1.0.0"
}
}

Once your configuration is complete, simply run yarn install from the root directory, and all your packages will be up to date, and ready to run.

Next steps — Integrate Lerna

Yarn workspaces is commonly used in conjunction with Lerna, a tool specifically used for multi-package projects. Lerna was released before Yarn Workspaces, however it quickly enabled support for the feature, and now acts more as a companion than a competitor. In fact, Lerna is still proving to be a vital tool for the Javascript community, currently on over 430k weekly downloads at the time of this writing. Lerna’s versioning and publishing tools are particularly useful to use with yarn workspaces.

Integrating Lerna into a project just requires the installation of the package, and addition of a small configuration file:

# add lerna as a dependencyyarn add lerna

The configuration, lerna.json, is typically saved next to package.json:

// lerna configuration{
"lerna": "3.14.0",
"packages": ["packages/*"],
"version": "1.0.0",
"npmClient": "yarn",
"useWorkspaces": true
}

Note that we are specifying the npmClient to be yarn, and that useWorkspaces is set to true, so Lerna knows to implement Yarn Workspace features instead of its native implementation. Like package.json, we are providing the package directories, this time with the packages property.

From here, check out the Getting Started section on Lerna’s Github.

Note: Deep diving into Lerna is out the scope of this article, but I will plug future articles here to expand on what we have introduced in this talk.


Summary

  • Typescript path aliases are useful for shortening import statements, and reaching modules from deep nested components. Paths can be easily implemented in Node and Angular projects, but are currently lacking support with Create React App — although support is on the roadmap.
  • Deploying your own Typescript packages, either publicly or privately via your own registry, is an effective means of reusing your code and decreasing the size of your final projects. As a result of using absolute imports, your import statements are shortened by using packages. This is a nice benefit, but shouldn’t be the primary means of using packages!
  • Where projects require multiple packages, yarn workspaces can be leveraged, and used in conjunction with Lerna. This makes more sense for larger projects, or where compartmentalising your components and sections of your apps can streamline maintenance of the app.

The solutions documented here may not all be suited for one particular project; it is instead advised to adopt them where they make sense.