02Criterio7 min

Lo que encontró Biome la primera vez que lo encendí

Llevo cinco proyectos pasados de ESLint y Prettier a Biome. Me esperaba ganar velocidad, y lo que más me ha servido es que en uno de ellos la primera pasada sacó 60 errores que llevaban meses ahí.

Por Salva Castells

Imagen de portada: Aris riyanto, CC BY-SA 4.0.
En este post

He pasado cinco proyectos de ESLint + Prettier a Biome, y en los dos o tres últimos ya ni me lo planteaba: abría el repo, borraba .prettierrc y eslint.config.mjs, quitaba dependencias y escribía un biome.json. Cuando una decisión se vuelve un gesto automático me entra la duda de si sigue teniendo sentido o si ya es pura inercia, así que me he sentado a repasar qué pasó en cada migración. Lo que me ha salido no es exactamente el post que habría escrito hace unos meses.

Paquetes que existen para que otros paquetes no se peleen

La primera fue en un SaaS de generación de imágenes. Un solo commit: fuera .prettierrc, fuera eslint.config.mjs, siete dependencias menos en package.json y 545 líneas menos en el lockfile. La config que borré era esta:

const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];

Mírala con calma, porque tiene gracia. FlatCompat existe para que la flat config entienda configuraciones con el formato antiguo. eslint-config-prettier existe para apagar las reglas de ESLint que se pegan con Prettier. Y luego tenía eslint-plugin-tailwindcss y prettier-plugin-tailwindcss haciendo cosas parecidas desde las dos orillas. Buena parte de mis dependencias de calidad estaban ahí para que las otras dependencias de calidad no se pisaran.

Eso fue lo que me empujó la primera vez, más que la velocidad. Biome formatea y lintea desde el mismo binario, así que el problema de coordinar dos herramientas simplemente no aparece.

Sesenta errores que nadie me había enseñado

Después de borrar viene el barrido de formato. En uno de los proyectos fueron 78 ficheros tocados en un commit que no hay forma humana de revisar, todo comillas y sangrías. Se hace, se mergea y a otra cosa. Mi único consejo es que vaya solo, sin ningún cambio de lógica mezclado, para que el git blame no te mienta más de lo necesario.

Lo interesante llegó al encender las reglas recomendadas en un bot de recordatorios para Telegram. La parte web soltó 60 errores. Mi primera reacción fue la de todo el mundo: "ya está el linter nuevo siendo pesado". Me puse a revisarlos con la intención de silenciar la mitad y acabé arreglando casi todos:

  • Campos del login sin htmlFor/id, o sea, labels que no estaban asociadas a nada. Un lector de pantalla leía un input sin nombre. ESLint con la config de Next nunca me lo había dicho (si no recuerdo mal, esa config trae un puñado de reglas de accesibilidad y la de labels no está entre ellas).
  • SVG decorativos con aria-hidden en su forma abreviada, que la regla no reconoce. Hay que escribir aria-hidden="true". Esta es la más discutible del lote, lo admito.
  • Un tipo TaskList importado que tenía el mismo nombre que un componente. Compilaba sin quejarse y estaba mal, que es la peor combinación posible.
  • Un componente de texto rotatorio que usaba el índice del map como key de cada carácter. Aquí me costó un rato: la tentación era poner un ignore, y lo que hice fue precalcular el offset absoluto de cada carácter dentro del memo para tener una key estable de verdad.

El resto los resolví con supresiones, pero todas con su motivo escrito: un efecto que tiene que ejecutarse una sola vez, loops que pintan esqueletos de tamaño fijo, un !important que pisa a react-remove-scroll-bar, y las img de los avatares que vienen del proveedor de OAuth. Biome te obliga a poner algo después de los dos puntos, y conviene tomárselo en serio:

// biome-ignore lint/correctness/useExhaustiveDependencies: se ejecuta una vez al montar, a propósito
useEffect(() => {
  track("board_opened");
}, []);

Un ignore sin explicación es deuda que alguien pagará sin saber por qué existe. Con la explicación, por lo menos el siguiente que llegue (normalmente yo, seis meses después) puede decidir si sigue valiendo.

Y la objeción obvia: "eso lo habrías encontrado igual subiendo las reglas de ESLint". Sí, claro. Pero no lo hice en meses, y con Biome me vino de serie el día de la migración. Para mí la diferencia real está entre las reglas que vienen puestas y las que tienes que acordarte de poner.

La velocidad me importa por un sitio concreto

Todas las comparativas enseñan un gráfico de milisegundos y a mí esa cifra me da bastante igual. Lo que me importa es dónde puedo enchufar el comando cuando tarda poco. Con ESLint, el lint vivía en CI, porque meterlo en el build local era sentarte a esperar cada vez. Con Biome el package.json quedó así:

"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."

Lint delante del build: si está en rojo, no compilas. El error ya no te lo encuentras quince minutos después en un runner, lo ves antes de que empiece el build. Con una herramienta lenta esa línea dura dos semanas, hasta que un día tienes prisa y la quitas "temporalmente".

Sé que hay gente que prefiere tenerlo en un hook de pre-commit y dejar el build limpio. Me parece igual de válido. Lo que no me vale es el lint que solo corre en un job aparte que nadie mira hasta que alguien pregunta por qué lleva tres semanas en rojo.

Lo que se pierde, que es algo

El ecosistema de plugins, lo primero. Si dependes de un plugin de ESLint muy concreto, en Biome no está y lo más probable es que no llegue. Yo tuve suerte con eslint-plugin-tailwindcss: el orden de clases ya lo resolvía el plugin de Tailwind para el formateador, y no lo eché de menos. Pero fue suerte, no lo tomes como regla.

Lo segundo, las reglas que usan información de tipos, tipo no-floating-promises de typescript-eslint. Yo no las perdí porque nunca las tuve activadas (la config de Next no las trae). Si tú sí las tienes y te han salvado alguna vez, mira bien qué cubre Biome hoy antes de migrar, porque ahí es donde más probable es que salgas perdiendo.

Y lo tercero es una tontería que me dio más guerra que lo anterior: la configuración se me fue desviando entre proyectos. Uno acabó con "indentStyle": "tab" y otro con "indentStyle": "space" e "indentWidth": 2, y semanas después tuve que hacer un commit cuyo único contenido era cambiar tabs por espacios en biome.json. Fija el $schema a la versión exacta y copia el mismo biome.json en todos los repos. Si la herramienta casi no tiene configuración, aprovéchalo y ten una sola.

Knip, la pieza que casi nadie usa

Mi stack de calidad hoy es corto: Biome, TypeScript estricto, tests, y knip para el código muerto. Knip es lo que más recomiendo y lo que menos veo en otros proyectos.

En el bot de Telegram, una pasada borró 19 ficheros muertos y 21 dependencias sin usar. Había subpaquetes @radix-ui/* redundantes con el meta-paquete, @react-three/fiber y three enteros, date-fns... y eslint, que seguía instalado meses después de haberlo quitado. Me dio bastante vergüenza. También apareció un grupo entero de acciones y casos de uso a los que ya no llamaba nadie.

Eso sí, knip no está en CI, y es a propósito. En un repo grande la primera pasada sacó 107 hallazgos antiguos. Si lo enganchas al gate, el primer día está en rojo, y un check que siempre está en rojo enseña a todo el equipo a ignorar el rojo. Lo paso a mano cada pocos meses, la poda va en su propio commit y no mergeo sin que pasen tsc y los tests.

La config necesita que le digas bien los puntos de entrada, o te marcará como muerta media aplicación. En un proyecto con App Router:

"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]

Los componentes de shadcn van en ignore porque siempre tienes más de los que usas y no pasa nada. sharp y tailwindcss van en ignoreDependencies porque nadie los importa, los usa la build.

Me queda una duda. El día que necesite una regla que solo existe como plugin de ESLint, supongo que acabaré con las dos herramientas conviviendo, que es justo lo que quería evitar. De momento no me ha pasado, y a la quinta migración empiezo a sospechar que igual no me pasa.