Reciprok Docs

Code style

TypeScript strict, imports, exports, formatage, patterns

Biome formate, on ne discute pas. Au-delà du formatage, voici les conventions à respecter à la main.

TypeScript strict

tsconfig.json doit avoir au minimum :

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true
  }
}

Pas de any

Jamais. Si tu ne sais pas le type :

  • Pour un input externe : unknown + validation Zod/TypeBox
  • Pour un objet partiel : utilise Partial<T> ou un type explicite
  • Pour une lib non typée : crée un fichier .d.ts minimal et type ce que tu utilises
// ❌
function process(data: any) { return data.foo; }

// ✅
function process(data: unknown) {
  const parsed = z.object({ foo: z.string() }).parse(data);
  return parsed.foo;
}

Pas de as, sauf cas justifiés

Le as désactive le check du compilateur. Il est tolérable uniquement pour :

  1. Narrowing après une vérification runtime que TS ne peut pas inférer (as const, as Foo après instanceof)
  2. Type assertions immuables (as const)
  3. Cas où Drizzle ou Eden a un trou d'inférence connu (commenter pourquoi)
// ✅ as const, immuable
const STAGES = ['new', 'qualifying', 'won'] as const;

// ❌ Cast aveugle
const member = data as Member;

// ✅ Validation puis narrowing
const member = MemberSchema.parse(data);

Préférer les union types aux enums TS

// ✅
type RequestStatus = 'new' | 'qualifying' | 'searching' | 'won' | 'lost';

// ❌ (enum TS)
enum RequestStatus { New, Qualifying, /* ... */ }

Les unions sont plus simples, n'ont pas de runtime overhead, et matchent directement les enums Zod / TypeBox / Drizzle.

interface vs type

  • interface pour les contrats publics (props de composant, signatures de service)
  • type pour les unions, intersections, mappings
interface MemberCardProps {
  member: Member;
  onClick?: () => void;
}

type RequestStatus = 'new' | 'qualifying' | 'won';
type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> };

Imports / exports

Toujours nommé, jamais default

// ✅
export function formatMember(m: Member) { /* ... */ }
import { formatMember } from './format-member';

// ❌
export default function formatMember(m: Member) { /* ... */ }
import formatMember from './format-member';

Pourquoi : les exports nommés sont refactorables par l'IDE (rename, find usages, etc.) et autodocumenté (le nom est imposé partout). Les default sont renommables n'importe comment, ce qui crée des incohérences.

Exception : les composants de page Next.js (app/[slug]/page.tsx) qui doivent exporter en default par convention du framework.

Ordre des imports

Biome formate automatiquement, mais l'ordre attendu est :

// 1. Bibliothèques externes
import { useState } from 'react';
import { z } from 'zod';

// 2. Packages workspace
import { Button } from '@reciprok/ui';
import { db } from '@reciprok/db';

// 3. Imports absolus du projet
import { authMacro } from '@/lib/auth-macro';
import { membersService } from '@/modules/members';

// 4. Imports relatifs
import { formatMember } from './format-member';
import type { MemberCardProps } from './member-card.types';

Type imports séparés

Quand tu importes uniquement un type, utilise import type :

import type { Member } from './members.types';
import { membersService } from './members.service';

C'est lintable via Biome (useImportType).

Pas d'imports relatifs profonds

// ❌
import { something } from '../../../../lib/something';

// ✅
import { something } from '@/lib/something';

Configurer les paths dans tsconfig.json pour @/*, @reciprok/*.

Fonctions

Function declarations vs arrow functions

  • Function declarations pour les fonctions de top level (services, helpers exportés)
  • Arrow functions pour les callbacks et les expressions
// ✅ Top level
export function calculateBudget(request: Request): number {
  return /* ... */;
}

// ✅ Callback
items.map((item) => item.id);

// ❌ Inutilement arrow au top level
export const calculateBudget = (request: Request): number => {
  return /* ... */;
};

Pourquoi : les function declarations ont un nom explicite dans les stack traces, et sont hoistées.

Toujours typer les retours publics

// ✅
export function findMember(id: string): Promise<Member | null> {
  return db.query.member.findFirst({ where: eq(member.id, id) });
}

// ❌ (TS infère mais ce n'est pas un contrat explicite)
export function findMember(id: string) {
  return db.query.member.findFirst({ where: eq(member.id, id) });
}

Pour les fonctions internes courtes, l'inférence suffit.

Async / await partout

Pas de .then() chaining. C'est moins lisible et moins debugable.

// ✅
const member = await findMember(id);
if (!member) throw new MemberNotFoundError(id);
const enriched = await enrichMember(member);

// ❌
return findMember(id)
  .then((m) => m ?? throw new MemberNotFoundError(id))
  .then(enrichMember);

Erreurs

Erreurs typées par module

Chaque module définit ses erreurs métier dans [feature].errors.ts.

modules/members/members.errors.ts
export class MemberNotFoundError extends Error {
  readonly code = 'MEMBER_NOT_FOUND';
  constructor(public readonly memberId: string) {
    super(`Member ${memberId} not found`);
  }
}

export class MemberInactiveError extends Error {
  readonly code = 'MEMBER_INACTIVE';
  constructor(public readonly memberId: string) {
    super(`Member ${memberId} is inactive`);
  }
}

L'onError Elysia mappe ces erreurs sur des codes HTTP (cf. Design d'API).

Pas de try/catch qui avale

// ❌ Tueur silencieux
try {
  await dangerousAction();
} catch (e) {
  console.log('oops');
}

// ✅
try {
  await dangerousAction();
} catch (e) {
  logger.error({ error: e }, 'dangerousAction failed');
  throw new DomainError('Failed to do thing', { cause: e });
}

Si tu catch, soit tu logges + relances, soit tu traites un cas précis (ex: catch un ConflictError spécifique pour le retry).

Composants React

Props typées en interface

interface MemberCardProps {
  member: Member;
  onClick?: () => void;
  className?: string;
}

export function MemberCard({ member, onClick, className }: MemberCardProps) {
  return /* ... */;
}

Pas de React.FC

Évite React.FC<Props>, il a des comportements bizarres avec les children et les types par défaut. Préférer la signature directe.

Children en ReactNode

interface CardProps {
  children: ReactNode;
}

Memoization parcimonieuse

useMemo, useCallback, React.memo ne servent qu'après mesure d'un problème de perf. Sinon ils ajoutent du bruit pour rien.

Server vs Client components

Sur Next.js App Router :

  • Par défaut, server component (pas de 'use client')
  • 'use client' seulement quand on a besoin d'interactivité, de state, ou de hooks
  • Les server components peuvent importer des client components ; l'inverse demande de passer en props

Drizzle

Pas de SQL brut sans raison

// ✅
await db.query.member.findFirst({ where: eq(member.id, id) });

// ✅ raw justifié, feature pgvector pas wrappée
await db.execute(sql`SELECT id FROM member ORDER BY embedding <=> ${queryVec} LIMIT 50`);

// ❌ raw injustifié
await db.execute(sql`SELECT * FROM member WHERE id = ${id}`);

Toujours passer par les schémas typés

import { member } from '@reciprok/db/schema';

Pas de strings hardcodées comme nom de table.

Index dans le schéma, pas dans des migrations manuelles

export const member = pgTable('member', {
  // ...
}, (table) => ({
  postalCodeIdx: index('member_postal_code_idx').on(table.postalCode),
  embeddingIdx: index('member_embedding_idx').using('ivfflat', table.embedding.op('vector_cosine_ops')),
}));

Drizzle génère la migration automatiquement.

Logs

Logger structuré

Pas de console.log en dehors du dev local. Utiliser le logger structuré (Logixlysia côté Elysia, équivalent côté workers).

// ❌
console.log('Member created', member.id);

// ✅
logger.info({ memberId: member.id, code: member.code }, 'member created');

Les logs sont consommés par Loki (cf. Monitoring). Le format JSON permet de filtrer en LogQL.

Pas de PII dans les logs

Pas d'email, pas de téléphone, pas de nom complet. Toujours logger l'ID interne.

// ❌
logger.info({ email: organizer.email }, 'organizer created');

// ✅
logger.info({ organizerId: organizer.id }, 'organizer created');

Commentaires

Quand commenter

  • Le pourquoi, pas le quoi
  • Les invariants non évidents (ex: "la transaction doit englober ces 3 opérations sinon les compteurs divergent")
  • Les liens vers des PRs ou tickets quand un choix non évident a été tranché
  • Les CONVENTION-EXCEPTION quand on déroge

Quand ne pas commenter

  • Pour décrire ce que le code fait déjà clairement (le code se documente lui-même)
  • Pour des TODO sans ticket associé
  • Pour expliquer un nom mal choisi (renomme plutôt)
// ❌ Inutile
// Increment the counter
counter++;

// ✅ Utile
// On incrémente AVANT le check pour éviter une double-écriture
// si deux requêtes arrivent en concurrence (cf. operations/concurrency)
counter++;
if (counter > MAX) throw new RateLimitError();

Formatage

Géré par Biome. Lance bun run check avant chaque commit.

Configuration de référence dans biome.json à la racine. Toute modification doit faire l'objet d'une PR séparée et d'une justification.

Voir aussi

On this page