En Semaine 2, on a posé le même POST /register en Express et en Go, côte à côte. Un fil est resté volontairement tendu : if err != nil partout, une erreur qui se renvoie au lieu de se lancer. Plaisir ou corvée ?
Aujourd'hui, on coupe ce fil. Pas pour te convaincre que Go a inventé la gestion d'erreurs — Node sait aussi gérer des erreurs. Pour montrer ce que Go refuse de te laisser faire, et ce que Node te laisse éviter jusqu'au jour où ça te revient en pleine face.
Promesse simple : à la fin, tu sauras pourquoi if err != nil n'est pas du bruit, et tu auras un schéma mental pour traduire tes try/catch en retours d'erreur Go — sans perdre le fil métier.
1. Ce que Node te laisse faire (et que tu fais)
En Node, une erreur est optionnelle jusqu'à ce qu'elle explose.
Tu peux écrire ceci, et ça tourne :
async function registerUser({ email, password }) {
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
throw new ConflictError('Cet email est déjà utilisé')
}
const passwordHash = await bcrypt.hash(password, 12)
const user = await prisma.user.create({ data: { email, passwordHash } })
await sendWelcomeEmail(user.email) // et si ça plante ?
return { id: user.id, email: user.email }
}
Trois façons de "gérer" l'échec de sendWelcomeEmail, toutes légales :
-
Ne rien faire. L'erreur remonte, le
catchdu handler (s'il existe) la transforme en 500. L'inscription a peut-être réussi en base, mais le client reçoit une erreur. Ou pire : pas decatch, et le process crache unUnhandledPromiseRejection. -
Avaler silencieusement. Un
try/catchvide, ou un.catch(() => {}). L'inscription réussit, l'email disparaît dans le vide, personne n'est prévenu. -
Logger et continuer. Comme en Semaine 1 : on catch, on
console.error, on renvoie quand même l'utilisateur créé. C'est le bon choix ici — mais rien dans le langage ne te l'impose.
Le point n'est pas que Node est mauvais. C'est que le langage te laisse choisir de ne pas choisir. Tu peux oublier un await. Tu peux oublier un catch. Tu peux laisser une Promise rejeter sans jamais la regarder. Le runtime proteste parfois, parfois trop tard, parfois jamais en local.
Et le pire : ça a l'air de marcher. Jusqu'à ce qu'un email ne parte pas en prod, ou qu'un findUnique échoue pour une raison réseau et que tu te retrouves avec un undefined que tu traites comme un user.
2. Ce que Go refuse
En Go, une fonction qui peut échouer le dit dans sa signature. Pas dans un commentaire. Pas dans un @throws que personne ne lit. Dans le type de retour.
func RegisterUser(db *sql.DB, in RegisterInput) (RegisteredUser, error)
Deux valeurs. Le résultat et l'erreur. L'appelant voit qu'il y a une erreur possible. Et le compilateur ne te laisse pas faire semblant :
user, err := RegisterUser(db, in)
// Si tu ignores err et que tu utilises user, tu as un bug.
// Go ne te force pas à checker err — mais la convention, l'outil, et
// tout le code autour te le rappellent à chaque ligne.
Petite nuance honnête, parce que je n'aime pas les slogans : Go ne compile pas un oubli de if err != nil. Tu peux écrire _ = err ou ignorer err et continuer. Ce n'est pas un garde-fou magique du compilateur.
Ce qui change, c'est ailleurs :
- L'erreur est une valeur, pas un événement qui traverse la pile en silence.
- Elle est dans le flux : tu la vois à chaque appel, dans le même ordre que le code métier.
- La convention est si forte (
if err != nil { return ..., err }) que le code qui l'ignore se remarque. Un_ = errcrie. Unthrowoublié en JS, non.
Node te laisse éviter. Go te met l'échec sous le nez, à chaque étape.
3. Le même register, vu par les erreurs
Reprends RegisterUser de la Semaine 2, mais cette fois on ne regarde que le chemin des erreurs.
func RegisterUser(db *sql.DB, in RegisterInput) (RegisteredUser, error) {
if !strings.Contains(in.Email, "@") {
return RegisteredUser{}, fmt.Errorf("%w: email invalide", ErrValidation)
}
if len(in.Password) < 8 {
return RegisteredUser{}, fmt.Errorf("%w: mot de passe trop court (8 caractères min)", ErrValidation)
}
var exists bool
if err := db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", in.Email,
).Scan(&exists); err != nil {
return RegisteredUser{}, err // erreur infra : on la remonte telle quelle
}
if exists {
return RegisteredUser{}, ErrConflict
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 12)
if err != nil {
return RegisteredUser{}, err
}
var id string
if err := db.QueryRow(
"INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id",
in.Email, string(hash),
).Scan(&id); err != nil {
return RegisteredUser{}, err
}
if err := sendWelcomeEmail(in.Email); err != nil {
// Choix métier explicite : l'email ne doit pas faire échouer l'inscription.
log.Printf("envoi email échoué: %v", err)
}
return RegisteredUser{ID: id, Email: in.Email}, nil
}
Chaque if err != nil est une décision écrite :
| Étape | Si ça échoue | Décision |
|---|---|---|
| Validation email / password | Données pourries | Erreur métier (ErrValidation), stop |
QueryRow EXISTS |
Base down / SQL cassé | Remonter l'erreur infra, stop |
| Email déjà pris | Conflit métier |
ErrConflict, stop |
bcrypt |
Rare, mais possible | Remonter, stop |
INSERT |
Contrainte, réseau, etc. | Remonter, stop |
| Email de bienvenue | SMTP down | Logger et continuer |
En Node, la dernière ligne du tableau est souvent un oubli ou un try/catch collé après coup. Ici, le "continuer malgré l'échec de l'email" est une ligne que tu lis dans le flux principal. Ce n'est plus un détail caché dans un catch trois niveaux plus haut.
C'est ça, le vrai contraste : l'échec n'est plus un événement latéral, c'est une branche du code métier.
4. errors.Is, wrapping, et pourquoi fmt.Errorf("%w")
En Semaine 1–2, côté Express, on avait des classes : ValidationError, ConflictError. Le handler faisait instanceof.
En Go, l'équivalent idiomatique :
var (
ErrValidation = errors.New("données invalides")
ErrConflict = errors.New("cet email est déjà utilisé")
)
Et pour ajouter du contexte sans perdre l'identité de l'erreur :
return RegisteredUser{}, fmt.Errorf("%w: email invalide", ErrValidation)
Le %w (wrap) dit : "cette erreur est encore ErrValidation, avec un message plus précis". Le handler peut alors faire :
user, err := RegisterUser(db, in)
switch {
case err == nil:
// 201 + JSON
case errors.Is(err, ErrValidation):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrConflict):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "erreur serveur", http.StatusInternalServerError)
}
Pourquoi pas comparer avec == ? Parce qu'après un wrap, err == ErrValidation est faux — tu as une nouvelle valeur qui contient ErrValidation. errors.Is déroule la chaîne. C'est l'instanceof des erreurs-valeurs.
Et le default ? C'est le filet pour tout ce que tu n'as pas nommé : timeout DB, disque plein, bug. Tu ne fuis pas ces erreurs — tu refuses de les exposer telles quelles au client. En Node, l'équivalent est souvent un next(err) vers un middleware d'erreur global. Même idée, mais en Go tu le vois dans le handler, pas trois fichiers plus loin.
5. La traduction mentale Node → Go
Si tu viens de JS, voilà le tableau que j'aurais voulu avoir le premier jour :
| Node | Go |
|---|---|
throw new Error(...) |
return zeroValue, err |
try { ... } catch (e) { ... } |
if err != nil { ... } juste après l'appel |
instanceof ValidationError |
errors.Is(err, ErrValidation) |
| Classe d'erreur custom |
var ErrX = errors.New("...") (+ wrap %w pour le détail) |
| Promise rejetée non catchée | Valeur error non inspectée (visible, mais pas bloquée par le compilateur) |
async/await qui "saute" au catch |
Retour anticipé return ..., err — le flux reste linéaire |
Un piège classique quand on débarque : vouloir recréer try/catch avec un panic / recover. Ça existe. Ce n'est pas le chemin pour les erreurs attendues (validation, conflit, DB down). panic, c'est pour "le programme ne peut plus continuer de façon sensée" — bug, invariant cassé. Pas pour "l'email est déjà pris".
Si tu te surprends à écrire panic pour une 400, tu es en train de réimporter le modèle mental Node dans un langage qui a un autre contrat.
6. Et le bruit dans le code ?
Objection légitime : "ok, mais c'est verbeux. Quatre lignes pour un appel qui en ferait une en JS."
Oui. Au début, ça grince. Puis trois choses arrivent.
D'abord, tu arrêtes de te demander où l'erreur est gérée. Elle est gérée ici, ou elle est renvoyée ici. Pas de chasse au catch manquant trois fonctions plus haut.
Ensuite, tu te mets à lire les erreurs comme du métier. "Si l'INSERT échoue, on remonte. Si l'email échoue, on log." Ce n'est plus du plomberie — c'est la spec, écrite en Go.
Enfin, tu remarques que Node n'était pas plus court : tu avais déplacé le coût. Moins de lignes dans le happy path, plus d'incertitude sur le chemin d'échec. Go inverse le trade-off : plus de lignes, moins d'incertitude.
Je ne vais pas te dire que c'est toujours agréable. Certains jours, le dixième if err != nil de la fonction me fatigue. Mais je ne me suis jamais réveillé à 3 h du matin en me demandant pourquoi une Promise avait rejeté en silence dans un job cron. Ce genre de nuit, Go me les a largement évitées.
7. Mini-exercice : le piège du _
Avant de refermer, un anti-pattern à reconnaître au premier coup d'œil :
user, _ := RegisterUser(db, in) // ❌ l'erreur disparaît
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user) // user peut être vide / partiel
Le _ dit au compilateur "je sais, je jette". En review, c'est un drapeau rouge sauf cas très documenté (ex. une fermeture de fichier où tu as déjà décidé d'ignorer l'erreur de Close — et encore, on débat).
La version honnête :
user, err := RegisterUser(db, in)
if err != nil {
// traduire en HTTP, ou remonter
return
}
Si tu dois vraiment ignorer, écris pourquoi en une ligne de commentaire. Le silence du _ sans explication, c'est exactement le comportement que Node te laissait avoir par défaut — et qu'on essaye de ne plus reproduire.
8. Ce qu'il faut retenir
Go ne t'apprend pas "à mieux gérer les erreurs" par magie. Il t'empêche de faire comme si l'échec n'existait pas. Chaque appel qui peut rater te tend une valeur error. Tu la traites, tu la wrap, ou tu l'assumes à voix haute. Ce que Node te laissait éviter — l'oubli, le catch manquant, la Promise orpheline — devient une décision visible dans le fichier.
Si tu ne retiens qu'une phrase : en Go, l'erreur est une valeur dans le flux ; en Node, c'est souvent un événement que tu peux rater.
La semaine prochaine : Handler / service / repository, expliqué à un dev Express — comment découper quand le fichier grossit, sans retomber dans le monolithe de routes de la Semaine 1.
La série continue.
Top comments (0)