• WHAT WE DO
  • SHOWCASE
  • WHY US
  • TESTIMONIALS
  • BLOG
Oct 29, 2024
The Dead Simple TypeScript Monorepo You Must Try
Written by Dan Kenan

TL;DR:

After a long search and many disappointments (lerna, nx, turbo...) we have arrived at a typescript monorepo that we can count on and enjoy. Even though our setup is minimal, we successfully use in medium-large and complex projects. It seamlessly handles diverse technologies and tools, making it extremely powerful yet simple to use for sharing code across many projects.

Overview

Unlike other solutions that require constant maintenance, numerous dependencies, clis, custom commands and come with complex limitations and opinionated structures, our monorepo setup is really simple and requires almost no setup and no dependencies. No need for complex tsconfig and references, no need for complex monorepo tools such as NX or TurboRepo. Nothing. It just works, thanks to PNPM.

Monorepo

In this article, we will share our approach to splitting and organizing code projects into a modern, super-easy-to-use TypeScript monorepo. Our methodology revolves around using PNPM workspaces exclusively, which we’ve found to be a powerful tool for managing monorepos. We’ll also explain our distinction between libraries and apps and how we handle their compilation in a seamless and efficient way.

Understanding the Two Types of Projects: Libraries and Apps

Our monorepo distinguishes between two types of projects:

1. Apps

Apps are deployable packages. These include:

  • Servers (e.g., Node.js services built with express, fastify, NestJS, socket.io etc )
  • Frontend applications (built with Vite, Next.js, or other frameworks)
  • Lambda functions or other serverless architectures

Apps are the only entities that get compiled in our repositories. Importantly, apps can also act as libraries—a shared app could expose its own API that other apps or libraries can consume.

2. Libraries

Libraries are shared pieces of code extracted into separate packages. They are reusable and serve as dependencies for both other libraries and apps. These are generally utility functions, shared types, components, or logic that other projects can consume.

In our solution, libraries are not compiled individually. Instead, they are used as raw source files and only get compiled when consumed by an app. This allows us to simplify the development workflow while keeping shared code in a modular and reusable format.

Note: While usually not compiled, we still want to hide this as an implementation details and not import from src directories.

import { x } from '@org/libname'; // GOOD! future proof and keep lib facade
import { y } from '@org/libname/src/z'; // BAD! breaks encapsulation

Note: Some achieve the ability to import { x, y } from '@org/libname' by avoiding the src dir in the lib and/or by mapping paths in tsconfig.json.
We reject both these approaches since we want our code to be natural and with no config specific to a library or to the monorepo setup.

It should just work by adding an dependency to the library in package.json and importing from that library like any other library. That's it.

The Trick: Specifying main and types in Library package.json

To make our setup work seamlessly, we use a small but effective trick in the package.json of each library. Here's how:

{
  "main": "src/index.ts",
  "types": "src/index.ts"
}

Why This Approach Works

By setting src/index.ts as both the main and types entry points in the library’s package.json:

1. Libraries are not precompiled

Instead of building each library independently, we allow the consuming app to compile the libraries as part of its own build process. This ensures that libraries always use the same TypeScript settings and compiler version as the consuming app.

2. Encapsulation

Libraries are still required to be treated as packages.
They must be included as dependencies in the app's package.json and imported using standard package import syntax:

import { x, y } from '@org/libname';

This prevents importing files directly from src/, which can lead to fragile code and non-standard module resolution.

3. Seamless Switching Between Compiled and Non-Compiled Libraries

With this setup, we can easily replace a non-compiled library with a compiled version without changing the import paths or structure.

Example Structure

Here’s a simplified example of how a monorepo using this approach might be organized:

monorepo/
├── apps/
│   ├── server/
│   │   ├── src/
│   │   │   └── index.ts
│   │   │   └── utils.ts
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── README.md
│   ├── frontend/
├── libs/
│   ├── lib1/
│   │   ├── src/
│   │   │   └── index.ts
│   │   │   └── internals1.ts
│   │   │   └── internals12.ts
│   │   ├── package.json
│   ├── lib2/
│   │   ├── src/
│   │   │   └── index.ts
│   │   │   └── lib2-internals.ts
│   │   ├── package.json
│   ├── lib3/
│   ├── lib4/
│   ├── lib5/
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── README.md

> Note: the tsconfig is only included in apps, not in libs.

# pnpm-workspace.yaml
packages:
  - apps/*
  - libs/*

A Closer Look at lib1/package.json

{
  "name": "@org/lib1",
  "version": "1.0.0",
  "main": "src/index.ts",
  "types": "src/index.ts",
  "dependencies": {}
}

Consuming the Library in an App

In server app, the library can be used as follows:

  1. Add the library as a dependency in server/package.json:

    {
      "dependencies": {
        "@org/lib1": "workspace:*"
      }
    }
    
  2. Import and use the library in your code:

    import { someUtilityFunction } from '@org/lib1';
    
    const result = someUtilityFunction();
    console.log(result);
    

Variation on Structure

Minimal, using packages

monorepo/
├── packages/
│   ├── lib-utils/
│   ├── lib-components/
│   ├── app-server/
│   └── app-frontend/
└── pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
  - packages/*

Complex, multi-level deep

monorepo/
├── pnpm-workspace.yaml
├── client/
│   ├── apps/
│   │   ├── admin-site/
│   │   └── app-site/
│   │   └── landing-site/
│   ├── libs/
│   │   └── app-core/
│   │   └── shared-components/
│   │   └── design-system/
├── common/
│   └── libs/
│       └── models/
├── devops/
│   ├── apps/
│   │   ├── base-devops/
│   │   ├── infra-stack/
│   │   └── load-testing/
│   └── libs/
│       ├── cdk-helpers/
├── server/
│   ├── apps/
│   │   ├── admin-api/
│   │   ├── chat-server/
│   │   ├── external-api/
│   │   ├── jobs/
│   │   ├── messages-listeners/
│   │   ├── webhooks/
│   │   └── widget/
│   ├── libs/
│   │   ├── business-context/
│   │   ├── chats/
│   │   ├── core/
│   │   │   ├── auth0-api/
│   │   │   ├── aws/
│   │   │   ├── cache/
│   │   │   ├── fastify/
│   │   │   ├── logging/
│   │   │   ├── mongo/
│   │   │   └── oauth2/
│   │   └── authorizers/
# pnpm-workspace.yaml
packages:
  - client/apps/*
  - client/libs/*

  - devops/apps/*
  - devops/libs/*

  - server/apps/*
  - server/libs/*
  - server/libs/*/*

  - common/libs/*

Advantages of Our Approach

1. Single Compilation Point

Apps compile all dependencies, ensuring consistent TypeScript configuration and reducing build complexity.

2. Modularity

By enforcing package-based imports, we maintain a clean and modular codebase.

3. Simplified Development Workflow

Developers can work on libraries and apps without worrying about separate compilation steps for libraries.

4. Mixing Nesting Level

You can nest at any level and even have multiple nesting levels supported at the same folder.

4. It Just Works

Simple and seamless like any other lib: pnpm add @my/lib and import {whatever} from '@my/lib'
No need for cli tools, propriety configuration, complex commands and processes.

5. Flexibility

The seamless switch between compiled and non-compiled libraries allows for easy transitions if we need to distribute compiled versions of libraries in the future.

6. Consistency

Using PNPM workspaces ensures all dependencies are managed centrally, reducing version mismatches and duplication.

Conclusion

Our TypeScript monorepo strategy provides a modern, efficient, and user-friendly development experience. By leveraging PNPM workspaces and a smart approach to library handling, we’ve created a setup that simplifies both development and deployment. Whether you’re working on shared libraries or deployable apps, this method ensures your codebase remains modular, maintainable, and easy to use.

Tags in this article

TypeScript
Tools

Share

Share on LinkedInShare on Facebook
Or

Copy this link

https://codygo.com/blog/dead_simple_typescript_monorepos
info@codygo.com
Zabotinskey 105 Ramat GanPisgat Dan Towers, Floor 40