Codementor Events

Share Code Effectively with Lerna

Published Dec 16, 2022
Share Code Effectively with Lerna

Hello Codementor! Today I'd like to explore with you how to set up a monorepo with Lerna. We will be building a monorepo with a frontend package, a backend package and a common package that will share common types and methods between the two. I will clearly set out the problem we encounter, a less-than-pretty solution and our optimized solution using Lerna.

The Problem

Because I love Typescript (and you should too!), I can't help but define really awesome object interfaces so I know what I'm working with at all times.
Screenshot 2022-12-16 at 04.38.32.png
So here's my awesome user. I also want to set up a function that returns me the user's full name.
Screenshot 2022-12-16 at 04.40.32.png
So what's the problem? Well, let's say I use this code in my backend, to set up my APIs and determine the correct typing. That's the right way to write APIs! But what if I ALSO want to use the interface and method to ensure I am fetching the correct types and for display purposes on the frontend? now what?

A Messy Solution

Get out your good ol' CopyPaste and have a common folder in both frontend and backend with the same code. It sounds nice, but what if my User becomes more complicated? I want to add a list of hobbies, an email address, a password. Now, I need to remember to update code in 2 places. And what if I have a reporting server now that serves the same purpose?
The list goes on.
The solution is untenable. What do we do now?

A Lerna Solution

We use a Monorepo! Essentially it's a single repository for all your code. This allows different repositories to share common code, as if it were an npm library. Lerna does all the symlinking and the magic behind the scenes. Let's see how it works!

Setup

Open an empty folder in your favourite code editor. Then run
npx lerna init
npm i

Screenshot 2022-08-29 at 08.30.10.png

This has set up a lerna repository with a packages folder and a lerna.json configuration file. You needn't mess with it if you don't have to.

inside packages, make three folders: common, frontend, backend
Let's go into common by typing in the terminal: cd packages/common
Then initialise an npm repository using npm init -y.
In the newly created package.json, change the name of the project to something like this:
Screenshot 2022-12-16 at 06.56.17.png
with the format "@{projectName}/{module}" as I have done. This is standard convention and best practice for monorepos.

I have added a script "dev-common" which just compiles the typescript and watches for changes

NB: The "main" needs to point to "dist/index.js", as we shall set up later

Now, still in common, let's install dependencies: npm i typescript
And let's create 3 files: index.ts, types.ts, functions.ts

In types.ts, add:

interface User {
  firstName: string;
  lastName: string;
}

export { User };

and for functions.ts:

import { User } from "./types";

const getUserFullName = (user: User) => `${user.firstName} ${user.lastName}`;

export { getUserFullName };

and for index.ts:

export * from "./functions";
export * from "./types";

Finally, we need a tsconfig.json file:

{
  "compilerOptions": {
    "target": "ES5",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Recommended"
}

NB: ensure the Target is ES5 so we can compile low-level javascript, and the declaration and declarationMap keys are true so it builds Typescript declaration files for us.

Let's go back to our root (cd ../..) and in our package.json define a script:

 "dev-common": "lerna run dev-common",

and let's run it: npm run dev-common

Screenshot 2022-12-16 at 07.01.02.png

Hopefully we get this. Notice that it builds a dist folder in the common package

Great! We've set up some common things. Let's now go into the backend folder:
cd ../backend
npm init -y
and change the project name as well.
Screenshot 2022-08-29 at 08.48.56.png

Let's make a quick server:

npm i express @types/express typescript
npm i --save-dev nodemon ts-node

Copy and paste the tsconfig.json from common into backend as well.
Create a file index.ts:

import { User } from "@seanh/common";
import express from "express";

const users: User[] = [
  { firstName: "Sean", lastName: "Hurwitz" },
  { firstName: "Batman", lastName: "Michaels" },
];

const app = express();

app.use(express.json());

app.get("/", (req, res) => {
  res.send("Hello!");
});

app.get("/users", (req, res) => {
  res.json(users);
});

app.listen(2050, () => {
  console.log(`server up on http://localhost:${2050}`);
});

Notice how we're importing the User interface from @seanh/common. But how does it know? Because in package.json, this is what you do:

{
  "name": "@seanh/backend",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
     "dev-backend": "tsc -w & nodemon dist/index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@seanh/common": "1.0.0",
    "@types/express": "^4.17.13",
    "express": "^4.18.1",
    "typescript": "^4.8.2"
  },
  "devDependencies": {
    "nodemon": "^2.0.19",
    "ts-node": "^10.9.1"
  }
}

I have manually added the first line to the dependencies there. Make sure the version matches exactly the version in the package.json of the common folder. (Also, I have added a simple script to start up the server).
Now, let's get to the root of our project in the terminal. you can use either cd ../.. or open a new terminal. Then run:
npx lerna bootstrap
This prompts lerna to make symlinks, symbolic linking of the different packages in the repository, according to their dependencies upon one another. Because the common folder is a dependency of the backend folder, lerna will link it to the backend and give it the behaviour of a package as if it was downloaded from npm!

on the root package.json, you can add a script:
"dev-backend": "lerna run dev-backend"
This will run the dev-backend script in any package that has it (in this case it is only the backend)

Running this hopefully will start the server, and going to localhost will return you:
Screenshot 2022-08-29 at 09.33.42.png
So you can see, the backend folder is successfully using our common folder's code!
Let's add a frontend now. go to the frontend folder:
cd packages/frontend
I will just spin up a React app for ease of use, but you can do anything really:

npx create-react-app --template typescript .

(note: you might get an error because lerna added a package.json to the frontend folder already. Just delete the file for now and rerun create-react-app)

Clean out App.tsx and add this code:

import { getUserFullName, User } from "@seanh/common";
import { useEffect, useState } from "react";

function App() {
  const [users, setUsers] = useState<User[]>([]);

  useEffect(() => {
    fetch("http://localhost:2050/users")
      .then((r) => r.json())
      .then((r) => setUsers(r));
  }, []);

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.firstName}>{getUserFullName(user)}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;

remember to add "@seanh/common": "1.0.0", in the dependencies of the frontend package.json.

Go back to the root (cd ../..) and run npx lerna bootstrap again
I added this script in the root package.json:
"dev-frontend": "lerna run start"

Now you can run npm run dev-frontend and hopefully the application spins up!

Screenshot 2022-12-16 at 07.08.54.png

Voila. The frontend is using the types and the method from the common library!

Conclusion

This brief article introduces monorepos and how to set them up for reusability of code. Let me know what you think in the comments section below!

Discover and read more posts from Sean Hurwitz
get started