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.
So here's my awesome user. I also want to set up a function that returns me the user's full name.
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
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:
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
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.
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:
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!
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!