DEV Community

Enol Casielles
Enol Casielles

Posted on

Construyendo un Monorepo en Typescript utilizando pnpm

En este artículo, explicaremos qué es un Monorepo, por qué es beneficioso utilizarlo en ciertos tipos de proyectos y cómo podemos construir uno utilizando pnpm

¿Qué es un monorepo?

Un monorepo, como su nombre indica, es un repositorio único que contiene múltiples paquetes o partes de un sistema. Pensemos, por ejemplo, en una arquitectura básica de un proyecto web. Podríamos tener, por un lado, un proyecto de frontend y, por otro, un proyecto de backend. Ambos serían totalmente independientes entre sí, contando con su propio repositorio, código fuente, dependencias, etc.

Por el contrario, cuando trabajamos con un monorepo, estos dos proyectos estarían bajo el mismo repositorio. Seguirían siendo totalmente independientes en términos de despliegue y ejecución, pero tendríamos la capacidad de compartir ciertos elementos entre ellos cuando sea necesario.

¿Cuando es interesante utilizarlos?

Para ilustrar esto mejor, imaginemos que en el ejemplo anterior tenemos que extenderlo y crear un tercer proyecto. Este proyecto será otro frontend que utilizará otro tipo de usuario, por ejemplo, un panel de administración. No sería de extrañar que este nuevo frontend comparta componentes de UI con el otro frontend, ¿verdad?

¿Cómo solucionamos esto cuando trabajamos en una arquitectura multirepo? Probablemente copiando estos componentes al nuevo proyecto, lo cual nos obliga a mantener componentes idénticos en dos lugares diferentes.

Tendría mucho más sentido que esos componentes estén en algún sitio donde ambos proyectos puedan utilizarlos, sin necesidad de duplicarlos. Algo como un paquete externo que ambos proyectos importen. Pues bien, un enfoque monorepo nos habilita el hacer esto de una forma sencilla y organizada.

Cuando estos dos proyectos viven bajo el mismo monorepo, podemos añadir un tercer proyecto (o paquete) que se encargue de implementar estos componentes comunes y que sea importado por ambos frontends.

Otro ejemplo

Otro ejemplo interesante es cuando queremos compartir clases o modelos entre el frontend y el backend. Frecuentemente, en el backend se definen clases con los modelos de respuesta que los diferentes endpoints devolverán y, a su vez, en el frontend defines también modelos para gestionar la respuesta.

Estas clases probablemente sean duplicadas en ambos proyectos, por lo que tendría mucho más sentido si se encuentran en un paquete independiente que ambos proyectos puedan importar. De nuevo, un caso que una arquitectura monorepo nos permite implementar y mantener de forma sencilla.

¿Cómo puedo construir un monorepo?

Vista la teoría y la justificación de por qué es interesante este enfoque, vamos a ver cómo podemos construir uno en TypeScript utilizando la herramienta pnpm, que es una alternativa a npm que, por su diseño y funcionamiento, encaja mucho mejor para la construcción de este tipo de arquitecturas.

Para empezar, nos ubicamos en la carpeta de trabajo y creamos un nuevo proyecto vacío utilizando pnpm.

pnpm init
Enter fullscreen mode Exit fullscreen mode

Debes tener pnpm instalado en tu máquina. En su web oficial puedes ver como instalarlo

Esto nos creará el típico package.json en la carpeta. Hasta este punto, lo que tenemos es un proyecto de Node estándar gestionado por pnpm. Para convertirlo a un proyecto monorepo, tenemos que crear un archivo pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"
Enter fullscreen mode Exit fullscreen mode

Este archivo especifica en qué rutas estarán los distintos paquetes de nuestro monorepo. En este caso, definimos dos posibles carpetas, apps y packages, por lo que podemos aprovechar ya para crear estas dos carpetas en nuestro proyecto, que de momento las dejamos vacías.

Hacemos esta distinción de carpetas para diferenciar las aplicaciones de los paquetes que van a ser utilizados por estas aplicaciones. Esto no es obligatorio; para el monorepo no hay distinción alguna entre lo que es una app y lo que es un paquete. La hacemos nosotros por la diferencia conceptual de lo que representan. A partir de ahora, cuando hablemos de un paquete, será aplicable a cualquiera de los dos tipos, ya que realmente, es así en la terminología utilizada en un monorepo con pnpm

Como ya hemos comentado, cada uno de los paquetes será una entidad independiente, nuevos proyectos pnpm que contarán con su package.json, con su listado de dependencias, scripts personalizados, etc. Pero lo importante es que al estar en el monorepo, será este quien se encargue de gestionar las dependencias de cada uno de ellos. Esto quiere decir que, cuando instalamos una dependencia en alguno de los paquetes, será el monorepo quien la instalará y quien decidirá que el alcance de dicha dependencia es para dicho paquete y no para el resto. Ahora bien, ¿qué pasa si instalamos una dependencia en el paquete raíz? Recordemos que el paquete raíz no deja de ser un proyecto pnpm también, por lo que se puede instalar algo a este nivel. Pues bien, lo que pasará es que dicha dependencia estará disponible en todos los paquetes.

Por lo general, no querremos instalar dependencias en raíz, pero sí es habitual hacer alguna excepción. Por ejemplo, algo que suelo hacer es instalar TypeScript y configurar eslint a este nivel de raíz. La razón de hacer esto es que sé seguro que todos los paquetes se van a escribir en TypeScript y quiero unificar las reglas de escritura de código en todos ellos. Pero esto no tiene por qué ser así; es una decisión totalmente de la cultura de ingeniería del proyecto.

Instalando dependencias globales

Dicho lo anterior, vamos a configurar estas dependencias visibles para todos los paquetes. Para ello, simplemente nos ubicamos en la raíz del proyecto y ejecutamos:

pnpm install typescript eslint -w
Enter fullscreen mode Exit fullscreen mode

Es importante destacar el uso de la flag -w. Cuando tratas de instalar algo en la raíz del monorepo, por defecto pnpm te lanza una advertencia para que tengas en cuenta que estás en raíz. Como no es lo habitual instalar dependencias a este nivel, pnpm considera que puede ser un error y te advierte. Con la flag -w indicas que eres consciente de ello y que quieres instalarlo en raíz, evitando que te salte el aviso. Este comportamiento también puede ser evitado estableciendo un ajuste en el proyecto, lo veremos más adelante.

Una vez hecho esto, ya podemos instalar estas dos dependencias y ver como aparecen en el package.json de raíz. Vamos a proceder ahora a configurar eslint, para ello vamos a utilizar el configurador que la propia dependencia nos ofrece. Pero antes de hacer esto es importante que tengamos en cuenta algo. Como decíamos antes, pnpm bloquea las instalaciones a nivel raíz si no llevan la flag -w. El configurador de eslint, a pesar de que sí permite la instalación usando pnpm, no utilizará esta flag -w, por lo que no podrá finalizar correctamente. ¿Cómo podemos solucionar esto? Estableciendo un ajuste a nivel proyecto que le indique a pnpm que ignore esta advertencia. Esto se hace creando un archivo .npmrc e insertando la siguiente línea:

ignore-workspace-root-check=true
Enter fullscreen mode Exit fullscreen mode

Con esto, la advertencia será ignorada y se podrá instalar en raíz sin la flag -w. Procedemos entonces a la configuración del eslint:

pnpm eslint --init
Enter fullscreen mode Exit fullscreen mode

El resultado de este configurador será la instalación de las dependencias necesarias y la creación del fichero .eslintrc.json en la raíz del proyecto:

{
    "env": {
        "browser": true,
        "es2021": true,
        "node": true
    },
    "extends": "standard-with-typescript",
    "parserOptions": {
        "ecmaVersion": "latest",
        "sourceType": "module"
    },
    "rules": {
    }
}

Enter fullscreen mode Exit fullscreen mode

En este punto, tenemos todo listo para empezar a crear paquetes en el monorepo 🚀

Definiendo el proyecto

Antes de empezar con la implementación de los diferentes paquetes, vamos a explicar brevemente el proyecto que, a modo de ejemplo, vamos a abordar. Será algo muy sencillo ya que queremos centrarnos en cómo funciona el monorepo y no en la aplicación en sí misma. Por esa razón, no voy a entrar en detalle en nada referente a la implementación de la aplicación. Solo mencionar que será la típica App de lista de tareas, con un frontend hecho con React + Vite y una API hecha con Express, ambos proyectos en TypeScript.

Además, para ilustrar mejor como usar un monorepo, crearemos un paquete llamado core que será consumido por ambas apps y que definirá clases o tipos que ambos puedan necesitar.

En el enlace al repo que dejo al final del artículo, podéis ver la implementación completa de cada parte

Creando el primer paquete

Vamos a empezar con la creación de la app para el frontend. Nos vamos a la carpeta apps y creamos un proyecto en React usando Vite:

pnpm create vite@latest front -- --template react-ts
Enter fullscreen mode Exit fullscreen mode

Seguimos las instrucciones del configurador para crear el proyecto React con TypeScript e instalar todos los paquetes necesarios. Si tras instalarlo, nos vamos a apps/front y hacemos pnpm run dev, deberíamos poder lanzar correctamente el proyecto React en ejecución. Vamos a hacer ahora algunas modificaciones para adaptarlo mejor al monorepo.

Antes que nada, vamos a decirle a Vite que utilice el puerto 3000 para nuestro frontend, en lugar de uno aleatorio como hace por defecto. Nos vamos al vite.config.ts y agregamos el siguiente objeto server:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000,
  }
})
Enter fullscreen mode Exit fullscreen mode

A continuación, vamos a adaptar el proyecto un poco mejor para vivir en el monorepo. La instalación por defecto de Vite configurará su propio TypeScript y eslint, pero como hemos comentado, nosotros queremos usar el del monorepo. Por tanto, lo que haremos es desinstalar localmente estas dependencias y eliminar todos los archivos. Nos vamos al package.json y eliminamos las siguientes dependencias:

typescript
eslint
@typescript-eslint/eslint-plugin
@typescript-eslint/parser
Enter fullscreen mode Exit fullscreen mode

En el momento de escribir este artículo, estas eran las dependencias relacionadas con TypeScript o eslint que Vite instalaba por defecto. Puede que en tu caso esto cambie un poco según la versión de Vite utilizada. Asegúrate de eliminar todas aquellas que tengan que ver con estas herramientas.

Una vez eliminadas en el package.json, ejecutamos pnpm install en apps/front para que la desinstalación se lleve a cabo. Por último, eliminamos el archivo .eslintrc para que no utilice la configuración de eslint que ahí se indica. Tras hacer esto, nuestro proyecto frontend ya debería usar las reglas de eslint definidas en raíz.

Ahora bien, es cierto que, según el tipo de proyecto TypeScript en el que estemos, quizá nos interese tener alguna configuración diferente. Por ejemplo, este paquete frontend es una aplicación con React y, en este caso, es muy común instalar el plugin de eslint para React eslint-plugin-react. No tiene sentido que instalemos este plugin en raíz y que nuestro proyecto api también esté expuesto a lo que dicte este plugin. Por tanto, vamos a ver cómo se puede adaptar la configuración de eslint para cada proyecto.

Primero, instalamos a este nivel front el paquete con el plugin mencionado.

cd apps/front
pnpm install -D eslint-plugin-react
Enter fullscreen mode Exit fullscreen mode

Luego creamos otro fichero .eslintrc.json a nivel front y agregamos lo siguiente:

{
  "extends": [
    "../../.eslintrc", 
    "plugin:react/recommended"
  ],
  "plugins": [
      "react"
  ],
  "rules": {}
}
Enter fullscreen mode Exit fullscreen mode

Así le estamos indicando lo siguiente: por un lado, que utilice dicho plugin para React; por otro lado, que extienda la configuración que tenemos definida en raíz; y por último, que extienda también lo que nos define el propio plugin. Finalmente, al igual que hemos hecho en raíz, definimos unas reglas personalizadas, por el momento vacías.

Si utilizas VSCode y la extensión para eslint, en este momento deberían aparecer errores en tus archivos. Por ejemplo, podemos abrir el App.tsx y ver cómo hay varios errores de sintaxis. Esto se debe a que ahora la configuración de eslint es la que viene de Standard, la guía de estilos que decidimos elegir cuando configuramos eslint en raíz, extendida además por la del plugin de React que le acabamos de añadir. Entonces, tenemos que adaptar nuestro código para cumplir con esto. Tenemos dos opciones para abordarlo: arreglar los errores que nos indica o modificar las reglas definidas. Esto, de nuevo, es una decisión que has de tomar según cómo quieras afrontar tu desarrollo.

En mi caso, lo que haré será una mezcla de ambas opciones. Añadiré las siguientes tres reglas a la configuración de eslint en raíz:

{
  //...
  "rules": {
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/no-floating-promises": "off",
    "@typescript-eslint/no-misused-promises": "off"
  }
}
Enter fullscreen mode Exit fullscreen mode

Y añadiré la siguiente a la configuración del proyecto front:

{
  //...
  "rules": {
    "react/react-in-jsx-scope": "off"
  }
}
Enter fullscreen mode Exit fullscreen mode

Una vez hecho esto, corregiré el resto de errores o advertencias que eslint me indique.

En este punto, ya tenemos todo listo para crear nuestra App. Como decía anteriormente, no vamos a entrar en detalles de la implementación. Puedes ir al repo y ver cómo está hecho.

Creando otro paquete para la Api

Ahora que ya hemos visto cómo crear y adaptar un proyecto al monorepo, vamos a hacer lo mismo para la API. Como habíamos dicho, implementaremos una API usando Express.

Nos vamos a la carpeta apps y creamos un nuevo directorio llamada api. En este caso no usaremos ningún generador. Entramos en la carpeta api y creamos un nuevo proyecto pnpm vacío.

cd apps
mkdir api && cd api && pnpm init
Enter fullscreen mode Exit fullscreen mode

Instalamos las dependencias necesarias

pnpm add express
pnpm add -D @types/express @types/node nodemon ts-node
Enter fullscreen mode Exit fullscreen mode

Básicamente, lo que estamos haciendo es instalar Express y como dependencias de desarrollo:

  • Los tipos de Express y Node
  • Nodemon, que es una herramienta que nos permite recargar el servidor al guardar cambios en los archivos
  • ts-node, que nos permite ejecutar directamente código Typescript

Agregamos un fichero nodemon.json en raíz para especificar como debe funcionar esta herramienta.

{
  "watch": ["src"],
  "ext": "ts",
  "exec": "ts-node ./src/index.ts"
}
Enter fullscreen mode Exit fullscreen mode

Así le estamos indicando es que debe escuchar a los archivos en la carpeta src con extensión .ts para que, cada vez que se guarde uno de ellos, ejecute ts-node en el archivo principal. Con eso, conseguimos ejecutar directamente la API con Typescript (ts-node se encarga por debajo de todo) y hacer que se recargue con cada cambio (nodemon se encarga)

Agregamos también el fichero tsconfig.json para indicar como se ha de realizar el transpilado a Javascript, así como algunas reglas de linting.

{
  "compilerOptions": {

    "module": "commonjs",
    "target": "ES2020",
    "outDir": "./dist",
    "incremental": true,
    "allowSyntheticDefaultImports": true,

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

  }
}


Enter fullscreen mode Exit fullscreen mode

Por último creamos los scripts en el package.json para realizar el dev, el build y el start.

{
  // ---
  "scripts": {
    //---
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "nodemon"
  },
}
Enter fullscreen mode Exit fullscreen mode

Creando un paquete compartido

Lo siguiente que vamos a hacer es crear el paquete core, en el que implementaremos aquellas cosas que puedan ser comunes en ambas aplicaciones frontend y API. Creamos una nueva carpeta en packages llamada core y, estando en ella, creamos de nuevo un proyecto pnpm básico.

cd packages
mkdir core
cd core
pnpm init
Enter fullscreen mode Exit fullscreen mode

A continuación creamos el tsconfig.json para especificar a Typescript como ha de realizar el compilado a Javascript.

{
  "compilerOptions": {

    "module": "ESNext",
    "target": "ES2015",
    "outDir": "./dist",
    "incremental": true,
    "allowSyntheticDefaultImports": true,
    "isolatedModules": true,

    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "removeComments": true,

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

  }
}

Enter fullscreen mode Exit fullscreen mode

Esta configuración es similar a la del proyecto API. En este caso, como sistema de módulos, usamos ESNext que lo hará más compatible para ser importado por las diferentes apps. Además, agregamos algunas propiedades que permiten generar las declaraciones de tipos y los archivos de mapas entre el código fuente y el código compilado. Esto es interesante ya que ayuda al IDE a entender la estructura del paquete y facilita cosas como ir a la definición de aquello que el paquete exporte cuando lo estamos usando en otro paquete

Creamos también un nodemon.json para poder realizar el compilado en modo desarrollo:

{
  "watch": ["src"],
  "ext": "ts",
  "exec": "npm run build"
}
Enter fullscreen mode Exit fullscreen mode

Y finalmente, agregamos los scripts para hacer el build y el dev en el package.json

{
  // ---
  "scripts": {
    //---
    "build": "tsc -p tsconfig.json",
    "dev": "nodemon"
  },
}

Enter fullscreen mode Exit fullscreen mode

Y con esto ya tenemos también listo este paquete para escribir el código que necesitemos.

Importando el paquete

Lo único que tenemos que hacer ahora es importar el paquete core en las apps. Esto lo hacemos como cualquier otro paquete, nos vamos al directorio de cada una de las apps y hacemos:

pnpm add core@workspace:^
Enter fullscreen mode Exit fullscreen mode

De esta forma, indicamos que instale un paquete llamado core que se encuentra en el workspace, para que lo busque directamente del monorepo y no en repositorios externos.

Una vez hecho esto, ya podremos utilizar en nuestras apps aquellos componentes que core exporte.

Scripts en paralelo

Para ir finalizando este artículo, vamos a ver una característica interesante de los monorepos que aún no hemos comentado: la posibilidad de ejecutar todos los scripts a la vez y en paralelo. Si nos fijamos, los tres paquetes tienen un script dev. Es muy posible que cuando estemos desarrollando, queramos tener levantados estos entornos de desarrollo en los tres paquetes. Pnpm nos permite hacer esto de una forma muy sencilla. Simplemente tenemos que ir al package.json de raíz y definir ahí el siguiente script dev:

{
  // ---
  "scripts": {
    //---
    "dev": "pnpm run --parallel dev",
  },
}
Enter fullscreen mode Exit fullscreen mode

Esto busca todos los scripts dev que haya en cada uno de los paquetes que conforman el monorepo y los ejecuta en paralelo.

Otra cosa interesante que nos ofrece pnpm es la posibilidad de filtrar un script a solo ciertos paquetes. Esto se hace utilizando la flag --filter. Esto nos permite crear scripts con las operaciones más habituales de desarrollo que podamos necesitar. Por ejemplo, podríamos configurar para levantar cada una de las apps por separado o las dos a la vez pero sin el paquete core.

{ 
  // ---
  "scripts": {
    //---
    "dev": "pnpm run --parallel dev",
    "dev:front": "pnpm run --filter front dev",
    "dev:api": "pnpm run --filter api dev",
    "dev:apps": "pnpm run --filter api --filter front dev"
  },
}

Enter fullscreen mode Exit fullscreen mode

Fin

Espero que este artículo sirva como una introducción para empezar a trabajar con monorepos en TypeScript usando pnpm. He tratado de realizar paso a paso la configuración que suelo utilizar para trabajar con este tipo de arquitecturas en proyectos TypeScript. Creo que puede servir como una buena base con la que empezar a construir proyectos usando este enfoque.

En posteriores artículos del blog, utilizaremos esto como base para crear ciertos proyectos y/o explicar algunas técnicas de desarrollo que suelo utilizar en mi día a día.

Por último, dejo por aquí el link al repositorio en github en el que está implementado todo lo que hemos ido haciendo en este artículo.

Top comments (0)