Vol. 7 · № 03 · Un atelier RubyA Ruby workshop

Exceptions vs valeurs de retour, ou quand ne pas lever Exceptions vs return values, or when not to raise

Une exception déroule la pile, construit une stack trace et détourne le flux — justifié pour l'imprévu, gaspillé pour l'attendu. Pour une absence ou un échec prévu : rends une valeur (nil, Result, fetch avec défaut). Le signal infaillible : si tu rattrapes l'exception là où tu la lèves, c'est un if déguisé. An exception unwinds the stack, builds a trace and diverts the flow — justified for the unforeseen, wasted on the expected. For an absence or an expected failure: return a value (nil, Result, fetch with a default). The infallible signal: if you rescue the exception where you raise it, it's a disguised if.

AudienceAudience
Dev qui lève par réflexe et veut réapprendre quand s'en abstenir Dev who raises by reflex and wants to relearn when to abstain
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ ResultfetchValeur de retour
SommaireContents ·
№ 01CadrageFraming3 min

Une exception est pour l'anormal — pas pour un cas qu'on attendait.An exception is for the abnormal — not for a case you expected.

On a appris à lever et à structurer les erreurs ; ce dernier numéro demande l'inverse : quand NE PAS lever. Une exception déclare un événement exceptionnel — un état impossible, une invariant brisée. Mais beaucoup de cas qu'on traite par raise ne sont pas exceptionnels du tout : une clé absente, une fin de fichier, un utilisateur introuvable, une saisie invalide. Ce sont des issues attendues, prévues, fréquentes. Les lever coûte une stack trace construite puis jetée, et détourne le flux pour une situation normale. Souvent, une valeur de retour — un nil, un objet Result — dit mieux et moins cher ce que l'exception criait.We learned to raise and to structure errors; this last issue asks the opposite: when NOT to raise. An exception declares an exceptional event — an impossible state, a broken invariant. But many cases handled by raise aren't exceptional at all: a missing key, an end of file, a user not found, invalid input. These are expected, anticipated, frequent outcomes. Raising them costs a stack trace built then discarded, and diverts the flow for a normal situation. Often, a return value — a nil, a Result object — says better and cheaper what the exception shouted.

La même absence : levée, ou rendue comme valeurThe same absence: raised, or returned as a value
# « introuvable » est-il une ERREUR, ou un résultat ATTENDU ?

# version exception : lève pour une absence pourtant banale
def find_user(id)
  users[id] || raise(NotFound, "user #{id}")   # construit une stack trace…
end
find_user(99) rescue nil                        # … qu'on jette aussitôt

# version valeur : l'absence est un nil, dit sans drame
def find_user(id) = users[id]   # => nil si absent
user = find_user(99)
greet(user) if user             # le cas « absent » fait partie du flux normal
Deux issues pour une absenceTwo outcomes for an absence
raiseraise
déroule la pile, backtraceunwinds the stack, backtrace
pour l'inattendufor the unexpected
valeur de retourreturn value
nil, Result — flux droitnil, Result — straight flow
pour l'attendufor the expected

L'exception est un détour de contrôle, justifié quand le cas est rare et anormal. Pour un cas prévu, le détour devient du bruit : la valeur de retour garde le flux droit, lisible, et sans le coût de la trace.The exception is a control detour, justified when the case is rare and abnormal. For an expected case, the detour becomes noise: the return value keeps the flow straight, readable, and without the trace's cost.

Le réflexe du numéroThe issue's reflex

« Est-ce vraiment exceptionnel, ou un cas normal qu'un nil ou un fetch avec défaut exprimait mieux ? » Si l'absence, l'échec ou la fin sont attendus et fréquents, ce n'est pas une exception : c'est une issue à rendre comme valeur, et à traiter dans le flux."Is this really exceptional, or a normal case a nil or a fetch with a default would express better?" If the absence, failure or end are expected and frequent, it's not an exception: it's an outcome to return as a value, and handle in the flow.

Le dernier numéro du volume.The volume's last issue.

Le Vol 7 referme sur le discernement. Le № 1 a donné le mécanisme, le № 2 l'arbre des types ; celui-ci pose la question d'avant tout cela — faut-il lever ?. Le dev venant d'un langage où l'exception est partout (ou de celui où tout est code d'erreur) doit réapprendre la ligne : l'exception pour ce qui ne devait pas arriver, la valeur de retour pour ce qui peut très bien arriver. Confondre les deux, c'est soit noyer le flux normal sous des rescue, soit perdre les vraies erreurs dans des codes de retour ignorés.Vol 7 closes on discernment. № 1 gave the mechanism, № 2 the type tree; this one asks the prior question — should you raise at all?. A dev from a language where exceptions are everywhere (or one where everything is an error code) must relearn the line: the exception for what shouldn't happen, the return value for what may well happen. Confusing the two either drowns the normal flow under rescues, or loses real errors in ignored return codes.

№ 02La surpriseThe surprise4 min

L'exception comme flux coûte une stack trace — et cache le flux.The exception as flow costs a stack trace — and hides the flow.

Détourner les exceptions vers le contrôle de flux a deux coûts. Le premier, mesurable : lever construit une trace de pile — Ruby capture où on est dans tout l'appel — puis on la jette aussitôt. Dans une boucle chaude, sur un cas fréquent, c'est un ordre de grandeur plus lent qu'un simple if. Le second, plus insidieux : le flux devient illisible. Un next ou un return cachés dans un rescue déguisent du contrôle ordinaire en gestion d'erreur ; le lecteur doit reconstituer que « invalide » n'était pas une panne mais une branche attendue. Rendre une valeur garde le chemin normal droit, visible, et gratuit.Diverting exceptions into control flow has two costs. The first, measurable: raising builds a stack trace — Ruby captures where you are in the whole call — then you discard it at once. In a hot loop, on a frequent case, it's an order of magnitude slower than a plain if. The second, more insidious: the flow becomes unreadable. A next or return hidden in a rescue disguises ordinary control as error handling; the reader must reconstruct that 'invalid' wasn't a failure but an expected branch. Returning a value keeps the normal path straight, visible, and free.

Une exception par ligne invalide : lent et opaqueOne exception per invalid row: slow and opaque
# Lever pour piloter le flux normal : coûteux ET illisible
def import(rows)
  rows.each do |row|
    begin
      save(row)
    rescue InvalidRow            # « invalide » est ATTENDU sur un import…
      next                       # … mais on s'en sert comme d'un « continue »
    end
  end
end

# à chaque ligne invalide : une exception levée, une stack trace construite,
# le flux dévié — pour un cas parfaitement prévu. Lent en boucle chaude,
# et le « next » caché dans un rescue brouille la lecture.

Sur un import, des lignes invalides sont attendues — c'est la norme, pas l'anomalie. Les traiter par raise/rescue construit une trace de pile par ligne fautive, pour la jeter aussitôt, et planque le « continue » dans un rescue. On paie un mécanisme conçu pour l'exceptionnel à chaque tour d'une boucle ordinaire.On an import, invalid rows are expected — the norm, not the anomaly. Handling them with raise/rescue builds a stack trace per faulty row, only to discard it, and hides the "continue" in a rescue. You pay a mechanism designed for the exceptional on every turn of an ordinary loop.

Utiliser une exception pour un cas prévu, c'est payer une stack trace pour un if — et la déguiser en panne.Using an exception for an expected case is paying a stack trace for an if — and disguising it as a failure.
№ 03La mécaniqueThe mechanics5 min

Les outils du « sans exception » : fetch, nil, Result, catch.The "no exception" toolkit: fetch, nil, Result, catch.

Ruby outille richement le « cas attendu sans lever ». fetch porte la nuance en un seul nom : fetch(k) lève si l'absence est anormale, fetch(k, défaut) ou fetch(k){…} rend une valeur si elle est prévue — tu choisis le sens. Beaucoup de méthodes du cœur offrent une variante sans exception, comme Integer(str, exception: false). Pour un échec qui doit voyager avec ses détails, un objet Result encapsule succès ou erreur en une donnée que l'appelant inspecte. Et catch/throw, souvent oublié, offre un vrai saut de contrôle — un break profond — sans détourner le système d'exception. Quatre outils pour garder l'exception à sa place : l'exceptionnel.Ruby richly tools the 'expected case without raising'. fetch carries the nuance in one name: fetch(k) raises if absence is abnormal, fetch(k, default) or fetch(k){…} returns a value if it's expected — you choose the meaning. Many core methods offer an exception-free variant, like Integer(str, exception: false). For a failure that must travel with its details, a Result object wraps success or error into data the caller inspects. And catch/throw, often forgotten, offers a real control jump — a deep break — without hijacking the exception system. Four tools to keep the exception in its place: the exceptional.

fetch et les variantes sans exceptionfetch and the exception-free variants
# 1. fetch : exception pour l'inattendu, défaut pour l'attendu
h = { a: 1 }
h[:b]                  # => nil          accès silencieux
h.fetch(:b)            # KeyError        « ça DOIT être là » → exceptionnel
h.fetch(:b, 0)         # => 0            « ça peut manquer » → défaut, sans lever
h.fetch(:b) { compute }# => défaut paresseux

# 2. la variante sans exception, quand elle existe
Integer("oops")                    # ArgumentError
Integer("oops", exception: false)  # => nil   l'échec attendu rendu en valeur
Result, et le saut catch/throwResult, and the catch/throw jump
# 3. un objet Result : porter le succès OU l'échec comme une donnée
Result = Data.define(:ok, :value, :error)

def parse_amount(str)
  Result.new(ok: true, value: Integer(str), error: nil)
rescue ArgumentError => e
  Result.new(ok: false, value: nil, error: e.message)
end

r = parse_amount(input)
r.ok ? save(r.value) : show(r.error)   # les deux issues, dans le flux

# 4. catch/throw : un SAUT de contrôle non-erreur (pas une exception)
found = catch(:hit) do
  grid.each { |row| row.each { |x| throw(:hit, x) if x.negative? } }
  nil                                  # rien trouvé
end
Choisir le porteur de l'issue.Choosing the outcome's carrier.

nil suffit quand l'absence n'a rien à dire de plus (« pas trouvé »). Un Result (ou un Data/Struct succès-ou-erreur) sert quand l'échec porte des détails que l'appelant exploitera. fetch avec défaut couvre le cas « valeur ou repli ». catch/throw, enfin, est le seul de la liste qui saute vraiment la pile — utile pour sortir d'une double boucle — mais ce n'est pas une erreur : ne le confonds pas avec raise/rescue. Le bon outil dépend d'une question : que doit savoir l'appelant ?nil is enough when absence has nothing more to say ("not found"). A Result (or a success-or-error Data/Struct) serves when the failure carries details the caller will use. fetch with a default covers "value or fallback". catch/throw, finally, is the only one that truly jumps the stack — handy to exit a nested loop — but it's not an error: don't confuse it with raise/rescue. The right tool depends on one question: what must the caller know?

Note le pont avec le Vol 1 : nil est un objet (pas un null), donc une issue « absente » rendue comme valeur reste un objet à qui l'on parle — avec &., to_a, un défaut via ||. Rendre une valeur n'affaiblit pas le typage du canard (Vol 3) : ça le garde, là où l'exception l'aurait court-circuité.Note the bridge to Vol 1: nil is an object (not a null), so an "absent" outcome returned as a value stays an object you talk to — with &., to_a, a default via ||. Returning a value doesn't weaken duck typing (Vol 3): it keeps it, where the exception would have short-circuited it.

№ 04Le piègeThe trap4 min

Lever puis rattraper aussitôt : un if déguisé en panne.Raise then rescue at once: an if disguised as a failure.

Le contre-exemple du numéro est un motif si courant qu'on ne le voit plus : fetch sans défaut qui lève, immédiatement enveloppé d'un rescue qui fournit la valeur de repli. L'exception naît et meurt dans la même méthode, à deux lignes d'écart — elle ne signale rien, elle pilote le flux. Et ce flux était une banalité : une clé de config absente, repliée sur un défaut. Le même fetch sait faire ça sans lever : avec un défaut ou un bloc, il rend le repli directement. On garde la version qui lève uniquement quand l'absence est vraiment anormale — quand la clé DOIT exister. Le réflexe : si tu rattrapes une exception au même endroit où tu la lèves, c'est qu'elle n'aurait pas dû être levée.The issue's counter-example is a pattern so common it's invisible: fetch without a default that raises, immediately wrapped in a rescue providing the fallback. The exception is born and dies in the same method, two lines apart — it signals nothing, it drives the flow. And that flow was trivial: a missing config key, falling back to a default. The same fetch does this without raising: with a default or a block, it returns the fallback directly. You keep the raising version only when the absence is truly abnormal — when the key MUST exist. The reflex: if you rescue an exception in the same place you raise it, it shouldn't have been raised.

L'exception levée et rattrapée à deux lignes d'écartThe exception raised and rescued two lines apart
# Le piège : lever pour une clé absente, puis la rattraper comme un défaut
def setting(key)
  CONFIG.fetch(key)          # lève KeyError si la clé manque…
rescue KeyError
  DEFAULTS[key]              # … qu'on rattrape AUSSITÔT : l'erreur EST le flux
end

setting(:timeout)
# à chaque clé absente — cas banal d'une config — une exception est levée,
# une stack trace construite puis jetée. On a écrit un « if » en trois lignes
# coûteuses, et déguisé une valeur par défaut en gestion d'erreur.
fetch avec bloc : le repli sans leverfetch with a block: fallback without raising
# Le défaut, rendu comme valeur : ni exception ni backtrace
def setting(key)
  CONFIG.fetch(key) { DEFAULTS[key] }   # le bloc fournit le repli, sans lever
end

setting(:timeout)              # => la valeur, ou le défaut — flux droit

# et si « absente » devait rester une vraie erreur, on garde fetch SANS bloc :
def setting!(key) = CONFIG.fetch(key)   # là, KeyError est justifié : la clé
                                        # DOIT exister, son absence est anormale
Le contre-exemple obligatoire.The mandatory counter-example.

Un test infaillible : si tu rattrapes une exception là même où tu la lèves, elle n'aurait pas dû être levée. Le couple fetch-sans-défaut + rescue KeyError est un if qui paie une stack trace. fetch(key) { default } dit exactement la même intention — « la valeur, ou ce repli » — sans détour ni coût. Et la distinction reste à ta main : fetch avec bloc pour l'attendu, fetch sans bloc quand l'absence est une vraie faute. Le mot ! au bout (setting!) signale d'ailleurs la version qui lève.An infallible test: if you rescue an exception in the very place you raise it, it shouldn't have been raised. The fetch-without-default + rescue KeyError pairing is an if paying for a stack trace. fetch(key) { default } states the exact same intent — "the value, or this fallback" — with no detour or cost. And the distinction stays in your hands: fetch with a block for the expected, fetch without when absence is a real fault. The trailing ! (setting!) signals the raising version.

Si tu rattrapes l'exception là où tu la lèves, elle n'aurait jamais dû être levée — c'était un if qui s'ignore.If you rescue the exception where you raise it, it should never have been raised — it was an if in disguise.
№ 05Bilan & éditoWrap-up & editorial2 min

L'exception pour l'exceptionnel — l'attendu se rend comme valeur.The exception for the exceptional — the expected is returned as a value.

Avant de lever, demande si le cas est vraiment anormal. Une exception déroule la pile, construit une stack trace et détourne le flux — justifié pour l'imprévu, du gaspillage pour l'attendu. Pour une absence, un échec prévu, une fin : rends une valeur. nil quand il n'y a rien à ajouter, un Result quand l'échec porte des détails, fetch avec défaut pour un repli, catch/throw pour un saut qui n'est pas une erreur. Et le signal infaillible : si tu rattrapes une exception là où tu la lèves, c'est un if déguisé.Before raising, ask whether the case is truly abnormal. An exception unwinds the stack, builds a trace and diverts the flow — justified for the unforeseen, wasteful for the expected. For an absence, an expected failure, an end: return a value. nil when there's nothing to add, a Result when the failure carries details, fetch with a default for a fallback, catch/throw for a jump that isn't an error. And the infallible signal: if you rescue an exception where you raise it, it's a disguised if.

La situationThe situationL'outilThe toolLe réflexeThe reflex
Anormal, imprévu, invariant briséAbnormal, unforeseen, broken invariantraise — déroule la pile, signale.raise — unwinds the stack, signals.Là, l'exception est à sa place.There, the exception belongs.
Absence sans détail (« pas trouvé »)Absence with no detail ('not found')nil (c'est un objet, Vol 1) + &..nil (it's an object, Vol 1) + &..Le flux gère l'absence, sans drame.The flow handles absence, no drama.
Valeur ou repli attenduValue or expected fallbackfetch(k, défaut) / fetch(k) { … }.fetch(k, default) / fetch(k) { … }.fetch sans bloc seulement si l'absence est anormale.fetch without a block only if absence is abnormal.
Échec qui porte des détailsFailure carrying detailsUn objet Result (succès ou erreur).A Result object (success or error).L'appelant inspecte, décide dans le flux.The caller inspects, decides in the flow.
« Exceptionnel » n'est pas « défavorable ». Une issue attendue, même un échec, se rend comme valeur — l'exception reste pour ce qui ne devait pas arriver."Exceptional" isn't "unwanted". An expected outcome, even a failure, is returned as a value — the exception stays for what shouldn't have happened.
Mot de l'éditeurFrom the editor

Fin du Vol 7 : le flux d'erreur est une question de discernement.End of Vol 7: the error flow is a question of discernment.

Trois numéros pour une seule maturité. Le № 1 a montré que l'erreur est un objet, trié par type le long de la pile. Le № 2 a fait de l'arbre des exceptions une interface, et tracé la frontière StandardError qu'on ne franchit pas. Le № 3 a posé la question d'avant toutes les autres : faut-il seulement lever ? Le fil commun n'est pas une technique, c'est un jugement — distinguer l'exceptionnel du prévu, et choisir le porteur de chaque issue. Un langage qui rend les exceptions faciles rend aussi facile d'en abuser : pour le contrôle de flux, pour le silence, pour la fausse prudence. La discipline du volume tient en une phrase : lève rarement, lève juste, et ne tais jamais.Three issues for a single maturity. № 1 showed the error is an object, sorted by type along the stack. № 2 made the exception tree an interface, and drew the StandardError boundary you don't cross. № 3 asked the question prior to all others: should you raise at all? The common thread isn't a technique, it's a judgment — telling the exceptional from the expected, and choosing each outcome's carrier. A language that makes exceptions easy also makes them easy to abuse: for control flow, for silence, for false caution. The volume's discipline fits one line: raise rarely, raise right, and never silence.

Prochain volume : Enumerable & l'itération. On quitte le flux d'erreur pour le réflexe fonctionnel de Ruby. On y récolte les fruits du Vol 2 (les blocs) : map, select, reduce ne sont pas des fonctions de liste mais des messages d'Enumerable, débloqués par un seul each. On verra qu'écrire ce each suffit à hériter cinquante méthodes, comment penser en transformations chaînées plutôt qu'en boucles, et le prix des tableaux intermédiaires que lazy rend inutiles.Next volume: Enumerable & iteration. We leave the error flow for Ruby's functional reflex. There we reap Vol 2's fruit (blocks): map, select, reduce aren't list functions but Enumerable messages, unlocked by a single each. We'll see that writing that each is enough to inherit fifty methods, how to think in chained transformations rather than loops, and the price of intermediate arrays that lazy makes needless.