My App

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 ! maintenant

null 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 undefined

Pas 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.md body frontmatter requirements

index.ts explicite

packages/api/src/modules/cardex/index.ts
// ✅ 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

packages/api/src/lib/errors.ts
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 :

packages/api/src/plugins/error-handler.ts
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 possible
  • logger.warn — anomalie non bloquante (retry en cours, fallback activé)
  • logger.info — event métier (check-in confirmé, commande créée) — parcimonieux
  • logger.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 --noEmit

Husky + 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 /design showcase
  • 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

On this page