Code style
Biome config, imports, exports, types stricts, null vs undefined, early returns
TypeScript strict, pas de
any, pas de!, pas d'export default sauf imposé par le framework. Biome automatise le reste.
Philosophie
On écrit le code pour être lu, pas écrit. Si un humain doit hésiter 5 secondes sur ce que fait une ligne, c'est qu'elle doit être réécrite. L'IA hérite des mêmes contraintes.
Biome automatise 90 % du style. Les 10 % restants sont des règles que le reviewer (humain ou IA) doit vérifier.
TypeScript strict
Dans tous les tsconfig.json du repo (via packages/config/tsconfig) :
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true
}
}Pas de any
// ❌
function process(data: any) { ... }
// ✅ explicite
function process(data: unknown) {
if (!isRecord(data)) throw new Error("invalid");
// ...
}
// ✅ générique
function process<T>(data: T): Transformed<T> { ... }Si tu écris any c'est que tu ne comprends pas les types. Reformule jusqu'à avoir le bon type.
Pas de ! (non-null assertion)
// ❌
const user = users.find((u) => u.id === id)!;
// ✅ gère le cas null
const user = users.find((u) => u.id === id);
if (!user) throw new NotFoundError("user");L'exception : tests où on sait que le fixture existe. Dans ce cas, assert avant :
const guest = await db.query.guest.findFirst({ where: eq(guest.id, TEST_ID) });
if (!guest) throw new Error("fixture missing");
// on utilise guest sans ! maintenantnull vs undefined
Convention simple :
null= intentionnellement absent (la DB dit "pas de valeur", l'API répond "pas trouvé")undefined= pas fourni (argument optionnel, clé manquante dans un objet)
// DB : null
const roomId: string | null = guest.roomId;
// Optional API input : undefined
function sendEmail(opts: { to: string; cc?: string }) { ... } // cc peut être undefinedPas de valeurs mixtes (null | undefined) sauf cas forcé par un type externe.
Imports
Ordre imposé par Biome
Biome groupe automatiquement dans cet ordre :
// 1. Node built-ins
import { readFile } from "node:fs/promises";
// 2. External packages
import { Elysia, t } from "elysia";
import { eq, and } from "drizzle-orm";
// 3. Workspace packages (@bell/*)
import { db } from "@bell/db";
import { logger } from "@bell/observability";
// 4. Relative imports
import { assertTransitionCheckIn } from "../../domain/check-in.machine";
import * as repo from "./cardex.repository";Chemins : workspace packages, pas relatifs profonds
// ❌ relative profond
import { db } from "../../../../../packages/db/src";
// ✅ workspace
import { db } from "@bell/db";Pas de barrel imports profonds
// ❌ tire tout le package en mémoire
import { Button, Card, Dialog } from "@bell/ui";
// ✅ import direct
import { Button } from "@bell/ui/components/button";
import { Card } from "@bell/ui/components/card";Les index.ts re-exportent explicitement ce qui est public, jamais export *.
type imports distincts
import { eq } from "drizzle-orm";
import type { DB } from "@bell/db";Biome ajoute type automatiquement si possible.
Exports
Pas d'export default
// ❌ (sauf pages Next.js, SKILL.md)
export default function getUser() { ... }
// ✅
export function getUser() { ... }Exceptions forcées par le framework :
page.tsx,layout.tsx,loading.tsx,error.tsx,middleware.ts(Next.js)SKILL.mdbody frontmatter requirements
index.ts explicite
// ✅ exports listés
export { cardexModule } from "./cardex.routes";
export type { GuestSummary } from "./cardex.schemas";
// ❌
export * from "./cardex.routes";export * rend l'API publique floue, casse les outils de tree-shaking, complique le refactor.
Fonctions
Signatures lisibles — objets en argument
// ❌ 4 args positionnels
function confirmArrival(orgId: string, guestId: string, userId: string, bypass: boolean) { ... }
// ✅ objet, explicite au call site
function confirmArrival(opts: {
organizationId: string;
guestId: string;
userId: string;
bypass?: boolean;
}) { ... }
confirmArrival({ organizationId, guestId, userId });Seuil : 3+ arguments = objet. Sauf pour les helpers universels simples (slugify(s: string), formatCurrency(n: number)).
Early returns
// ❌ pyramid of doom
function handle(x: Foo | null) {
if (x) {
if (x.active) {
if (x.status === "ready") {
// ...
}
}
}
}
// ✅ early returns
function handle(x: Foo | null) {
if (!x) return;
if (!x.active) return;
if (x.status !== "ready") return;
// ...
}Pas de fonctions > 50 lignes
Dans un fichier, une fonction qui dépasse 50 lignes est probablement à extraire en sous-fonctions privées. Pas de règle stricte, mais un signal fort.
Null coalescing, optional chaining
// ✅
const name = user?.firstName ?? "Guest";
// ❌ long si/sinon
const name = user && user.firstName ? user.firstName : "Guest";Async / Await
Jamais de .then().catch() chain, sauf dans des handlers où await n'est pas possible.
// ✅
try {
const user = await getUser(id);
return user;
} catch (err) {
logger.error(err);
throw err;
}
// ❌
return getUser(id).then((user) => user).catch((err) => {
logger.error(err);
throw err;
});Pas de Promise.all qui cache les erreurs
// ⚠️ si un fail, l'autre est orphelin
const [guests, rooms] = await Promise.all([getGuests(), getRooms()]);
// ✅ Promise.allSettled si on veut tolérer une erreur partielle
const results = await Promise.allSettled([getGuests(), getRooms()]);Objets et immutabilité
Préférer const, jamais let si possible
// ❌
let result = [];
for (const item of items) result.push(transform(item));
// ✅
const result = items.map(transform);let n'est acceptable que pour les accumulateurs où map/reduce est moins lisible.
Préférer immutabilité
// ❌ mutation
function addTag(user: User, tag: string) {
user.tags.push(tag);
return user;
}
// ✅ spread
function addTag(user: User, tag: string) {
return { ...user, tags: [...user.tags, tag] };
}Pour les structures profondes, on peut utiliser structuredClone() ou immer si ça devient compliqué.
Erreurs
Custom error classes
export class NotFoundError extends Error {
constructor(public resource: string) {
super(`${resource} not found`);
this.name = "NotFoundError";
}
}
export class InvalidTransitionError extends Error {
constructor(public from: string, public to: string, public domain: string) {
super(`Invalid ${domain} transition: ${from} → ${to}`);
this.name = "InvalidTransitionError";
}
}Mapping HTTP dans les routes
Les erreurs métier ne connaissent pas HTTP. C'est la couche routes qui les mappe :
export const errorHandler = new Elysia().onError(({ error, set }) => {
if (error instanceof NotFoundError) {
set.status = 404;
return { error: error.message };
}
if (error instanceof InvalidTransitionError) {
set.status = 400;
return { error: error.message, details: { from: error.from, to: error.to } };
}
set.status = 500;
logger.error(error);
return { error: "Internal server error" };
});Logs
Utiliser le logger, pas console
// ❌
console.log("Guest arrived:", guestId);
// ✅
import { logger } from "@bell/observability";
logger.info({ guestId, organizationId }, "Guest arrived");Niveaux précis
logger.error— quelque chose a cassé, action humaine possiblelogger.warn— anomalie non bloquante (retry en cours, fallback activé)logger.info— event métier (check-in confirmé, commande créée) — parcimonieuxlogger.debug— dev uniquement
Pas de PII
// ❌
logger.info({ email: user.email }, "Login attempt");
// ✅
logger.info({ userId: user.id, emailHash: hashEmail(user.email) }, "Login attempt");Un linter custom no-pii-in-logs à écrire détecte les call sites risqués (fields nommés email, phone, address, ssn, etc. passés directement).
Biome config
biome.json à la racine impose :
{
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always"
}
},
"linter": {
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error",
"noConsoleLog": "warn"
},
"style": {
"noNonNullAssertion": "error",
"useImportType": "error",
"useExportType": "error"
},
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
}
}
}
}Appliquer
bun check # format + lint fix
bun check-types # tsc --noEmitHusky + lint-staged applique biome check --write au pre-commit sur les fichiers modifiés. CI bloque le merge si les checks fail.
Ce qu'on ne teste pas (intentionnellement)
- Composants UI purs sans logique (juste JSX + props) → validation visuelle via
/designshowcase - Helpers triviaux (
formatDate,cn) → c'est l'inférence TS qui valide - Config files (
next.config,drizzle.config) → validés à l'usage
Règle : on teste le comportement métier, pas la syntaxe.
Lien avec les autres conventions
- Naming — kebab-case des fichiers
- File thresholds — max lignes par fichier
- Testing — naming des tests, colocation
- Factorisation — quand DRY, quand duplication OK