Maîtriser le Développement Full-stack Type-Safe : De l'API au Frontend avec TypeScript
Maîtriser le Développement Full-stack Type-Safe : De l'API au Frontend avec TypeScript

Intégration d'une Base de Données et Gestion de la Persistance Type-Safe

Introduction

Dans le contexte de notre cours "Maîtriser le Développement Full-stack Type-Safe : De l'API au Frontend avec TypeScript", l'intégration d'une base de données est une étape fondamentale. Cependant, se contenter d'une simple connexion ne suffit pas. Pour maintenir la robustesse et la cohérence de notre application de bout en bout, il est impératif d'adopter une gestion de la persistance type-safe.

Traditionnellement, l'interaction avec une base de données implique souvent l'écriture de requêtes SQL brutes ou l'utilisation d'ORMs (Object-Relational Mappers) qui ne fournissent pas toujours une inférence de type satisfaisante. Cela peut entraîner des erreurs silencieuses, des incohérences entre le schéma de la base de données et les modèles de données de notre application, et une expérience de développement frustrante.

Cette leçon vise à vous équiper des connaissances et des outils nécessaires pour intégrer une base de données de manière type-safe dans votre application TypeScript. Nous explorerons comment définir vos schémas, gérer les migrations et effectuer des opérations CRUD (Create, Read, Update, Delete) avec une fiabilité accrue grâce à l'inférence de type.

Objectifs de la Leçon

À la fin de cette leçon, vous serez capable de :

  • Comprendre l'importance de la persistance type-safe dans une architecture full-stack TypeScript.
  • Choisir et mettre en œuvre un outil de persistance offrant une excellente inférence de type (tel que Prisma).
  • Modéliser votre base de données et gérer les migrations de manière structurée.
  • Réaliser des opérations CRUD complexes tout en bénéficiant de la sécurité des types à chaque étape.
  • Intégrer efficacement votre logique de base de données dans votre API TypeScript.

I. Pourquoi la Persistance Type-Safe est Cruciale ?

La gestion de la persistance des données est au cœur de presque toutes les applications. Sans une approche type-safe, plusieurs problèmes peuvent survenir, compromettant la qualité et la maintenabilité de votre projet :

  • Fiabilité et Réduction des Erreurs à l'Exécution :
    • Les erreurs courantes telles que les fautes de frappe dans les noms de colonnes, l'accès à des propriétés inexistantes ou l'assignation de types de données incorrects sont détectées au moment de la compilation, et non à l'exécution.
    • Cela réduit considérablement le risque de bugs en production, ce qui est particulièrement critique pour les applications manipulant des données sensibles.
  • Amélioration de l'Expérience Développeur (DX) :
    • L'autocomplétion intelligente fournie par l'IDE (grâce aux types générés) accélère le développement et minimise les allers-retours à la documentation ou à la base de données pour vérifier les noms de colonnes ou les types.
    • Le refactoring devient plus sûr et plus facile, car TypeScript vous alertera si des modifications de schéma affectent d'autres parties du code.
  • Cohérence de l'Architecture Full-stack :
    • Dans un environnement TypeScript de bout en bout, avoir des types précis pour vos interactions avec la base de données permet de propager ces types jusqu'à votre API et potentiellement jusqu'à votre frontend.
    • Cela garantit que les données envoyées et reçues par l'API correspondent exactement à ce qui est attendu par la base de données et par le client, éliminant les devinettes et les erreurs d'interface.

II. Choisir un Outil de Persistance Type-Safe

Plusieurs outils existent pour interagir avec les bases de données en TypeScript. Ils se répartissent généralement en deux catégories :

  • ORMs (Object-Relational Mappers) : Ils mappent les tables de la base de données à des objets dans votre code, vous permettant d'interagir avec la base de données en utilisant des paradigmes orientés objet. Exemples : TypeORM, Sequelize, Prisma.
  • Query Builders : Ils fournissent une API fluide pour construire des requêtes SQL de manière programmatique, offrant un contrôle plus granulaire que les ORMs mais nécessitant souvent plus de code pour gérer le mappage aux objets. Exemples : Knex.js, Drizzle ORM.

Pour notre objectif de persistance type-safe dans un contexte full-stack TypeScript, nous nous concentrerons sur les outils qui excellent en matière d'inférence de type. Prisma est un excellent candidat pour sa philosophie "Schema-first" et sa capacité à générer un client TypeScript entièrement typé. Drizzle ORM est une autre alternative de plus en plus populaire, reconnue pour sa légèreté et ses capacités d'inférence de type avancées.

Dans cette leçon, nous utiliserons Prisma comme outil de choix pour sa maturité, sa communauté active et l'excellente qualité de ses types générés.

Introduction à Prisma

Prisma est une suite d'outils comprenant :

  • Prisma Client : Un ORM de nouvelle génération, type-safe et auto-généré, pour interagir avec votre base de données depuis votre application Node.js ou TypeScript.
  • Prisma Migrate : Un outil de gestion de migrations déclaratif.
  • Prisma Studio : Une interface graphique pour visualiser et manipuler vos données.

La force de Prisma réside dans son schema.prisma, un fichier unique où vous définissez vos modèles de données de manière déclarative. À partir de ce schéma, Prisma génère tout le nécessaire : les migrations pour votre base de données et le client TypeScript entièrement typé.

III. Modélisation de la Base de Données avec Prisma Schema

La première étape avec Prisma est de définir votre schéma de base de données. Ce schéma sera la source unique de vérité pour vos modèles de données et leurs relations.

1. Installation de Prisma

Pour commencer, installez Prisma CLI dans votre projet :

npm install prisma --save-dev
npx prisma init

La commande npx prisma init va :

  • Créer un dossier prisma/ à la racine de votre projet.
  • Ajouter un fichier prisma/schema.prisma.
  • Ajouter un fichier .env pour la chaîne de connexion à la base de données.

Le fichier schema.prisma ressemblera initialement à ceci :

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql" // ou "mysql", "sqlite", "sqlserver", "mongodb"
  url      = env("DATABASE_URL")
}

// Vos modèles de données iront ici

Assurez-vous que la variable DATABASE_URL dans votre fichier .env pointe vers votre base de données.

2. Définition du schema.prisma

Nous allons définir un modèle simple pour des User (utilisateurs) et des Post (articles), où un utilisateur peut avoir plusieurs posts.

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[] // Relation avec le modèle Post (un utilisateur a plusieurs posts)
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id]) // Relation avec le modèle User (un post appartient à un auteur)
  authorId  Int      // Clé étrangère
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Explication du schema.prisma

  • generator client : Configure la génération du client Prisma (ici, pour JavaScript/TypeScript).
  • datasource db : Définit la source de données. provider spécifie le type de base de données (PostgreSQL, MySQL, SQLite, etc.) et url pointe vers la chaîne de connexion.
  • model User { ... } et model Post { ... } : Ce sont vos modèles de données.
    • Chaque champ a un nom, un type (ex: String, Int, Boolean, DateTime), et des attributs (ex: @id, @unique, @default, @relation).
    • @id : Marque le champ comme clé primaire.
    • @default(autoincrement()) : Définit une valeur par défaut auto-incrémentée pour les IDs numériques.
    • @unique : Assure que la valeur de ce champ est unique à travers la table.
    • String? : Le ? indique que le champ est optionnel (nullable).
    • Post[] : Dans le modèle User, posts Post[] définit une relation un-à-plusieurs. Un User peut avoir plusieurs Posts. C'est le côté "plusieurs" de la relation.
    • author User @relation(...) et authorId Int : Dans le modèle Post, ceci définit l'autre côté de la relation.
      • author User est le champ relationnel qui lie un Post à un User.
      • @relation(fields: [authorId], references: [id]) : Spécifie que ce champ author est lié au champ id du modèle User via la clé étrangère authorId dans la table Post.
      • authorId est la clé étrangère concrète dans la table Post qui stocke l'ID de l'auteur.

3. Génération du Client Prisma

Après avoir modifié votre schema.prisma, vous devez générer (ou régénérer) le client Prisma. Cette étape est cruciale car c'est elle qui crée les types TypeScript que nous utiliserons.

npx prisma generate

Cette commande va créer un dossier node_modules/@prisma/client contenant le client Prisma et surtout, tous les types TypeScript correspondants à vos modèles.

IV. Gérer les Migrations de Base de Données

Les migrations sont des scripts qui décrivent les changements de schéma de votre base de données au fil du temps. Prisma Migrate vous permet de gérer ces changements de manière déclarative et versionnée.

1. Le Rôle des Migrations

  • Historique des changements : Chaque migration est un instantané des modifications de schéma, ce qui permet de revenir en arrière ou d'appliquer des changements de manière contrôlée.
  • Collaboration : Les migrations facilitent le travail en équipe en assurant que tous les développeurs travaillent avec la même version du schéma de la base de données.
  • Déploiement fiable : En production, les migrations garantissent que votre application utilise le bon schéma de base de données.

2. Création et Application de Migrations

Pour créer une nouvelle migration basée sur les changements de votre schema.prisma :

npx prisma migrate dev --name init_database
  • migrate dev : Crée une nouvelle migration ou met à jour les migrations existantes en fonction des différences entre votre schema.prisma et l'état actuel de votre base de données.
  • --name init_database : Donne un nom descriptif à votre migration.

Cette commande va :

  1. Comparer votre schema.prisma avec le schéma de votre base de données.
  2. Générer un fichier SQL dans le dossier prisma/migrations pour appliquer les modifications nécessaires (ex: créer les tables User et Post).
  3. Appliquer ces changements à votre base de données de développement.
  4. Générer (ou régénérer) le client Prisma.

Si vous avez déjà une base de données existante avec des données, vous pouvez utiliser npx prisma migrate diff ou npx prisma db pull pour générer un schéma à partir d'une base existante, puis npx prisma migrate deploy pour appliquer des migrations en production. Pour le développement, migrate dev est la commande la plus utilisée.

Pour appliquer les migrations à un environnement de production (ou un autre environnement) sans interagir interactivement et sans générer de nouvelles migrations :

npx prisma migrate deploy

Ceci est utile dans les pipelines CI/CD.

V. Opérations CRUD Type-Safe avec Prisma Client

Maintenant que notre schéma est défini et que nos migrations sont gérées, nous pouvons utiliser le client Prisma pour interagir avec notre base de données de manière entièrement type-safe.

1. Initialisation du Client Prisma

Commencez par initialiser le client Prisma dans votre application. Il est courant de créer une instance singleton du client pour la réutiliser à travers votre application.

// src/lib/prisma.ts ou src/utils/prisma.ts
import { PrismaClient } from '@prisma/client';

// Déclarer une variable globale pour PrismaClient pour éviter
// des problèmes de hot-reloading en développement
declare global {
  var prisma: PrismaClient | undefined;
}

// Instancier PrismaClient
const prisma = global.prisma || new PrismaClient({
  log: ['query', 'info', 'warn', 'error'], // Optionnel: pour logger les requêtes SQL
});

// En développement, réutiliser l'instance globale
if (process.env.NODE_ENV === 'development') {
  global.prisma = prisma;
}

export default prisma;

Maintenant, vous pouvez importer prisma dans n'importe quel fichier de votre backend.

2. Création de Données (create)

Pour créer un nouvel enregistrement, utilisez la méthode create sur le modèle correspondant. Remarquez comment TypeScript vous guide sur les champs requis et leurs types.

// Exemple de création d'un utilisateur
import prisma from '../lib/prisma'; // Ou l'emplacement de votre instance Prisma

async function createUserAndPost() {
  try {
    const newUser = await prisma.user.create({
      data: {
        email: 'alice@example.com',
        name: 'Alice Smith',
        // Si 'name' était un champ requis, TypeScript vous l'indiquerait.
        // Si vous essayez d'ajouter un champ non défini dans le modèle 'User', TypeScript affichera une erreur.
      },
    });
    console.log('Utilisateur créé :', newUser);
    // newUser est de type { id: number, email: string, name: string | null }

    const newPost = await prisma.post.create({
      data: {
        title: 'Mon premier article avec Prisma',
        content: 'Ceci est le contenu de mon premier article.',
        published: true,
        author: {
          connect: { id: newUser.id }, // Lie le post à l'utilisateur nouvellement créé
        },
      },
    });
    console.log('Article créé :', newPost);
    // newPost est de type { id: number, title: string, content: string | null, published: boolean, authorId: number, createdAt: Date, updatedAt: Date }

  } catch (error) {
    console.error('Erreur lors de la création :', error);
  } finally {
    await prisma.$disconnect(); // Déconnectez-vous de la base de données
  }
}

// createUserAndPost();
  • Sécurité des types : Si vous essayez d'omettre un champ non optionnel ou de fournir un type incorrect, TypeScript affichera une erreur à la compilation.
  • Relations imbriquées : Prisma permet de créer des relations directement lors de la création d'un enregistrement, comme author: { connect: { id: newUser.id } }.

3. Lecture de Données (findUnique, findMany)

Les méthodes de lecture de Prisma sont puissantes et offrent une grande flexibilité tout en maintenant la sécurité des types.

// Exemple de lecture de données
import prisma from '../lib/prisma';

async function readData() {
  try {
    // 1. Trouver un utilisateur par son ID (champ unique)
    const user = await prisma.user.findUnique({
      where: {
        email: 'alice@example.com',
      },
    });

    if (user) {
      console.log('Utilisateur trouvé :', user);
      // user est de type User | null.
      // Si vous accédez à user.name, TypeScript sait que name peut être null.
      // console.log(user.nonExistentField); // <-- TypeScript affichera une erreur ici !
    } else {
      console.log('Utilisateur non trouvé.');
    }

    // 2. Trouver tous les posts, avec leur auteur (inclure une relation)
    const allPosts = await prisma.post.findMany({
      where: {
        published: true,
      },
      include: {
        author: true, // Inclut les informations de l'auteur dans le résultat
      },
      orderBy: {
        createdAt: 'desc',
      },
      select: { // Sélectionne explicitement les champs que vous voulez
        id: true,
        title: true,
        author: {
          select: {
            name: true,
            email: true,
          },
        },
      },
    });

    console.log('\nPosts publiés avec leurs auteurs :', allPosts);
    // Le type de 'allPosts' sera un tableau d'objets avec seulement les champs 'id', 'title' et 'author'
    // où 'author' aura les champs 'name' et 'email'.

    // Exemple de type inféré après sélection :
    // const postItem = allPosts[0];
    // if (postItem) {
    //   console.log(postItem.title); // OK
    //   console.log(postItem.author?.name); // OK
    //   // console.log(postItem.content); // <-- Erreur TypeScript car 'content' n'a pas été sélectionné
    // }

  } catch (error) {
    console.error('Erreur lors de la lecture :', error);
  } finally {
    await prisma.$disconnect();
  }
}

// readData();
  • findUnique : Recherche un enregistrement par un de ses champs @unique ou par sa clé primaire (@id).
  • findMany : Recherche plusieurs enregistrements.
  • where : Permet de filtrer les résultats.
  • include : Permet de charger des relations. L'objet résultant inclura les données des modèles liés, et TypeScript le reflétera dans le type retourné.
  • select : Permet de choisir précisément les champs à récupérer. C'est excellent pour optimiser les requêtes et affiner les types retournés.
  • Sécurité des types : Les types des objets retournés par Prisma Client sont dynamiquement ajustés en fonction de vos clauses include et select. C'est une caractéristique extrêmement puissante de Prisma.

4. Mise à Jour de Données (update, updateMany)

Mettre à jour des données est également une opération type-safe.

// Exemple de mise à jour de données
import prisma from '../lib/prisma';

async function updateData() {
  try {
    // 1. Mettre à jour un utilisateur spécifique
    const updatedUser = await prisma.user.update({
      where: {
        email: 'alice@example.com',
      },
      data: {
        name: 'Alicia Smith',
      },
    });
    console.log('Utilisateur mis à jour :', updatedUser);
    // updatedUser est de type User

    // 2. Mettre à jour plusieurs posts (par exemple, tous les posts non publiés)
    const { count } = await prisma.post.updateMany({
      where: {
        published: false,
      },
      data: {
        published: true,
      },
    });
    console.log(`${count} posts ont été publiés.`);

  } catch (error) {
    console.error('Erreur lors de la mise à jour :', error);
  } finally {
    await prisma.$disconnect();
  }
}

// updateData();
  • update : Met à jour un seul enregistrement, identifié par where.
  • updateMany : Met à jour plusieurs enregistrements correspondant au where donné. Il retourne un objet { count: number } indiquant le nombre d'enregistrements mis à jour.

5. Suppression de Données (delete, deleteMany)

La suppression d'enregistrements suit une logique similaire.

// Exemple de suppression de données
import prisma from '../lib/prisma';

async function deleteData() {
  try {
    // 1. Supprimer un post spécifique
    // NOTE: Il est préférable de supprimer un post par son ID plutôt que par un champ non unique si possible.
    const deletedPost = await prisma.post.delete({
      where: {
        id: 1, // Supposons que l'ID 1 existe. En production, utilisez des IDs dynamiques.
      },
    });
    console.log('Article supprimé :', deletedPost);

    // 2. Supprimer un utilisateur (et ses posts si la base de données est configurée pour le CASCADE DELETE)
    // Attention : la suppression en cascade doit être gérée soit au niveau de la base de données,
    // soit manuellement dans votre application Prisma si elle n'est pas configurée.
    const deletedUser = await prisma.user.delete({
      where: {
        email: 'alice@example.com',
      },
    });
    console.log('Utilisateur supprimé :', deletedUser);

  } catch (error) {
    // Gérer les erreurs, par exemple si l'enregistrement n'existe pas
    if (error instanceof Error && 'code' in error && error.code === 'P2025') {
      console.warn('L\'enregistrement à supprimer n\'a pas été trouvé.');
    } else {
      console.error('Erreur lors de la suppression :', error);
    }
  } finally {
    await prisma.$disconnect();
  }
}

// deleteData();
  • delete : Supprime un seul enregistrement.
  • deleteMany : Supprime plusieurs enregistrements.
  • Gestion des erreurs : Il est important de gérer les cas où l'enregistrement à supprimer n'existe pas. Prisma lève une erreur spécifique (P2025) dans ce cas.

VII. Intégration dans une API TypeScript

L'intégration de Prisma dans une API TypeScript (par exemple, avec Express.js, Next.js API Routes, ou NestJS) se fait naturellement. Le client Prisma est généralement utilisé au sein des "services" ou "contrôleurs" pour interagir avec la base de données.

Voici un exemple simplifié de l'utilisation de Prisma dans un "service" pour une API Express :

// src/services/userService.ts
import prisma from '../lib/prisma';
import { User } from '@prisma/client'; // Importe les types générés par Prisma

export class UserService {
  // Récupérer tous les utilisateurs
  async getAllUsers(): Promise<User[]> {
    return prisma.user.findMany();
  }

  // Récupérer un utilisateur par son ID
  async getUserById(id: number): Promise<User | null> {
    return prisma.user.findUnique({
      where: { id },
    });
  }

  // Créer un nouvel utilisateur
  async createUser(email: string, name?: string): Promise<User> {
    return prisma.user.create({
      data: {
        email,
        name,
      },
    });
  }

  // Mettre à jour un utilisateur
  async updateUser(id: number, data: Partial<User>): Promise<User | null> {
    // Partial<User> assure que 'data' peut avoir des champs optionnels
    return prisma.user.update({
      where: { id },
      data,
    });
  }

  // Supprimer un utilisateur
  async deleteUser(id: number): Promise<User> {
    return prisma.user.delete({
      where: { id },
    });
  }
}

Dans un contrôleur Express, vous pourriez l'utiliser comme suit :

// src/controllers/userController.ts
import { Request, Response } from 'express';
import { UserService } from '../services/userService';

const userService = new UserService();

export const getUsers = async (req: Request, res: Response) => {
  try {
    const users = await userService.getAllUsers();
    res.json(users);
  } catch (error) {
    res.status(500).json({ message: 'Erreur lors de la récupération des utilisateurs', error });
  }
};

export const getUser = async (req: Request, res: Response) => {
  const userId = parseInt(req.params.id, 10);
  if (isNaN(userId)) {
    return res.status(400).json({ message: 'ID utilisateur invalide' });
  }

  try {
    const user = await userService.getUserById(userId);
    if (!user) {
      return res.status(404).json({ message: 'Utilisateur non trouvé' });
    }
    res.json(user);
  } catch (error) {
    res.status(500).json({ message: 'Erreur lors de la récupération de l\'utilisateur', error });
  }
};

// ... autres contrôleurs pour créer, mettre à jour, supprimer

Les points clés de cette intégration sont :

  • Types de retour clairs : Les méthodes de UserService retournent des Promise<User[]> ou Promise<User | null>, ce qui rend les signatures de fonctions prévisibles et facilite la consommation de ces données par le frontend.
  • Validation des entrées : Dans un contrôleur, vous validez les données reçues de la requête (par exemple, parseInt(req.params.id)). Ces données validées sont ensuite passées au service où Prisma garantit la sécurité des types pour l'interaction avec la base de données.
  • Séparation des préoccupations : La logique métier (services) est séparée de la logique de gestion des requêtes HTTP (contrôleurs), rendant le code plus modulaire et testable.

Conclusion et Prochaines Étapes

Vous avez maintenant une solide compréhension de l'importance et de la mise en œuvre de la persistance type-safe dans votre développement full-stack TypeScript. L'utilisation d'outils comme Prisma transforme l'interaction avec la base de données d'une source potentielle d'erreurs en une fondation stable et prévisible pour votre application.

Récapitulatif des Avantages Clés :

  • Robustesse : Détection des erreurs de schéma à la compilation, non à l'exécution.
  • Productivité : Autocomplétion, vérification des types et refactoring plus facile.
  • Cohérence : Des types clairs et propagés de la base de données jusqu'à l'API, voire le frontend.

En maîtrisant la modélisation de données, les migrations et les opérations CRUD type-safe avec Prisma, vous posez une pierre angulaire pour la construction d'une API backend robuste et maintenable.

Prochaines Étapes

Dans la suite de notre parcours "Maîtriser le Développement Full-stack Type-Safe", nous allons :

  1. Construire notre API REST/GraphQL : Utiliser les services que nous venons de créer pour exposer des endpoints clairs et bien typés.
  2. Validation des Données d'Entrée : Mettre en place des mécanismes de validation stricts pour les données reçues par l'API afin de garantir leur intégrité avant toute interaction avec la base de données.
  3. Intégration Frontend : Utiliser les types générés par l'API dans notre application frontend pour une expérience de développement end-to-end type-safe.

La persistance type-safe est le maillon essentiel qui relie votre base de données à votre code applicatif, garantissant que chaque pièce de votre pile full-stack parle le même langage.