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

Quand le canard trahit — frontières & types optionnels When the duck betrays you — boundaries & optional types

Le duck typing déplace l'erreur à l'exécution, loin de sa cause : le NoMethodError au pire endroit. La parade n'est pas de tout vérifier, mais de regagner une garantie à la frontière — au runtime sur la donnée (dry-struct), ou à l'analyse sur le code (Steep, RBS). Une frontière, deux façons de la tenir. Duck typing pushes the error to runtime, far from its cause: the NoMethodError at the worst place. The remedy isn't to check everything, but to regain a guarantee at the boundary — at runtime on the data (dry-struct), or at analysis on the code (Steep, RBS). One boundary, two ways to hold it.

AudienceAudience
Dev convaincu par le duck typing, prêt à en borner le coût Dev sold on duck typing, ready to bound its cost
Format
Self-paced
ChapitresChapters
5
Date
Fév 2026 Feb 2026
≈ 19 min ●●●○ Frontièredry-structSteep
SommaireContents ·
№ 01CadrageFraming3 min

Le canard finit par trahir — et toujours au pire endroit.The duck eventually betrays you — always at the worst place.

Deux numéros à vanter le duck typing ; voici sa facture, en toute honnêteté. Quand on fait confiance au message sans jamais vérifier, une donnée malformée traverse sans bruit — et l'erreur n'éclate pas là où elle est née (l'entrée), mais douze appels plus loin, au fond du domaine, sous la forme d'un NoMethodError sur nil. Le bug que Rust attrape à la compilation, Ruby te le sert à l'exécution, au moment le moins commode.Two issues praising duck typing; here's its bill, honestly. When you trust the message without ever checking, malformed data flows through silently — and the error doesn't surface where it was born (the entry), but twelve calls deep, in the domain, as a NoMethodError on nil. The bug Rust catches at compile time, Ruby serves you at runtime, at the least convenient moment.

Une donnée manquante à l'entrée, un crash au fondMissing data at the entry, a crash deep down
# à l'entrée HTTP : on fait confiance au JSON reçu, sans rien vérifier
payload = JSON.parse(request.body)     # { } — "name" est absent ce jour-là
user = User.new(payload)

# … 12 frames plus loin, au fond de la couche de rendu :
def greeting(user) = "Bonjour #{user.name.split.first} !"

greeting(user)
# NoMethodError: undefined method 'split' for nil
#   app/views/header.rb:7   ← l'erreur surgit ICI…
#   … mais la cause est à l'entrée HTTP, douze appels plus haut
La distance cause → effetThe cause → effect distance
name absentname missing
entrée HTTPHTTP entry
⟶
12 frames
nil.split
couche de rendurender layer

La cause (donnée invalide) et l'effet (le crash) sont séparés par douze frames. Plus la donnée voyage avant de trahir, plus le débogage coûte.The cause (invalid data) and the effect (the crash) are twelve frames apart. The further the data travels before betraying you, the costlier the debugging.

Le réflexe du volumeThe volume's reflex

« Valide le canard à la frontière (l'entrée externe), fais-lui confiance à l'intérieur. » Le duck typing reste roi dans ton domaine ; mais à la lisière — JSON, params HTTP, ligne CSV, réponse d'API — tu regagnes une garantie, une seule fois, pour que l'erreur naisse là où on peut la lire."Validate the duck at the boundary (external input), trust it inside." Duck typing stays king in your domain; but at the edge — JSON, HTTP params, CSV line, API response — you regain a guarantee, once, so the error is born where it can be read.

L'honnêteté qui clôt le volume.The honesty that closes the volume.

Ce numéro ne renie pas le duck typing — il en nomme le prix et le situe. Le coût n'est pas « ça plante parfois », c'est « ça plante loin de la cause ». La parade n'est pas de tout vérifier partout (on y reviendra, c'est le piège), mais de choisir où regagner une garantie : au runtime à la frontière, ou statiquement, hors exécution.This issue doesn't disown duck typing — it names its price and places it. The cost isn't 'it crashes sometimes', it's 'it crashes far from the cause'. The remedy isn't checking everything everywhere (we'll get to that — it's the trap), but choosing where to regain a guarantee: at runtime at the boundary, or statically, outside execution.

№ 02La surpriseThe surprise4 min

Le bug ne change pas. Ce qui change, c'est où il éclate.The bug doesn't change. What changes is where it surfaces.

La donnée malformée provoquera une erreur de toute façon — la question n'est pas « si » mais « où ». Sans frontière, elle voyage au cœur du domaine et explose à des frames de sa cause, sur un nil cryptique. Avec une frontière, tu la valides une seule fois à l'entrée : l'erreur naît là, avec un message qui nomme le champ fautif, et tout le code en aval peut faire confiance. Déplacer l'erreur vers sa cause, c'est tout l'enjeu.The malformed data will cause an error anyway — the question isn't 'whether' but 'where'. Without a boundary, it travels into the domain and blows up frames away from its cause, on a cryptic nil. With a boundary, you validate it once at the entry: the error is born there, with a message naming the offending field, and all downstream code can trust. Moving the error toward its cause is the whole point.

La donnée brute traverse, et explose au fondRaw data flows through, blows up deep
# Sans frontière : la donnée brute circule, et trahit où elle veut
def create_user(params)        # params : un Hash venu de nulle part de sûr
  Account.new(params)          # on transmet tel quel
end

def charge(account)            # … bien plus loin
  account.balance * 1.2        # NoMethodError si balance est nil/absent
end                            # l'erreur naît ICI, la cause est à l'entrée

Le Hash brut circule de méthode en méthode. Personne ne s'engage sur son contenu, donc l'erreur attend le dernier moment — un NoMethodError dans charge, alors que le vrai coupable est create_user, voire l'appelant HTTP au-dessus.The raw Hash flows from method to method. No one commits to its contents, so the error waits until the last moment — a NoMethodError in charge, while the real culprit is create_user, or even the HTTP caller above.

Une frontière ne supprime pas l'erreur. Elle la ramène à sa cause — et lui donne un message lisible.A boundary doesn't remove the error. It brings it back to its cause — and gives it a readable message.
№ 03La mécaniqueThe mechanics6 min

Deux façons de regagner la garantie : à l'exécution, ou à l'analyse.Two ways to regain the guarantee: at runtime, or at analysis.

« Valider à la frontière » a deux outils, qui répondent à la même question à deux endroits différents. dry-struct vérifie et coerce la donnée AU RUNTIME, une fois, à l'entrée : après ça, le domaine fait confiance. Steep + RBS ajoutent un typage statique graduel par-dessus le code, vérifié HORS exécution : pour les frontières qu'on veut prouver sans payer un contrôle à chaque appel. L'un borne la donnée, l'autre prouve le code — et tu choisis lequel, où.'Validate at the boundary' has two tools, answering the same question in two different places. dry-struct checks and coerces data AT RUNTIME, once, at the entry: after that, the domain trusts. Steep + RBS add gradual static typing over the code, checked OUTSIDE execution: for the boundaries you want to prove without paying a check on every call. One bounds the data, the other proves the code — and you choose which, where.

dry-struct — valider & coercer au runtime, à l'entréedry-struct — validate & coerce at runtime, at the entry
require "dry/struct"
module Types; include Dry.Types(); end

class Order < Dry::Struct
  attribute :id,       Types::Strict::Integer        # exige un Integer, sans coercition
  attribute :amount,   Types::Coercible::Decimal     # "9.90" → 9.90
  attribute :currency, Types::String.enum("EUR", "USD")  # borné à deux valeurs
  attribute :note,     Types::String.optional        # peut être nil, explicitement
end

Order.new(id: 1, amount: "9.90", currency: "EUR", note: nil)
# OK — amount coercé en Decimal, currency validé, note nil autorisé

Order.new(id: "oops", amount: "9.90", currency: "EUR")
# Dry::Struct::Error: [Order.new] "oops" (String) has invalid type for :id
#   violates constraints (type?(Integer, "oops") failed)
#   ↑ à la frontière, message clair, AVANT que la donnée ne voyage
RBS — la signature, hors du codeRBS — the signature, outside the code
# sig/checkout.rbs — une signature, hors du code, qu'on choisit d'écrire
class Checkout
  def total: (Array[Order]) -> BigDecimal   # une somme de Decimal
end
Steep — la vérifie sans exécuterSteep — checks it without running
# checkout.rb
class Checkout
  def total(orders)
    orders.sum { |o| o.amont }   # faute de frappe : amont au lieu d'amount
  end
end

# $ steep check
# checkout.rb:3: [error] Type Order does not have method 'amont'
#   ↑ attrapé SANS exécuter le code, à l'analyse — comme un compilateur
Runtime borné vs statique optionnel.Bounded runtime vs optional static.

dry-struct agit à l'exécution, sur la donnée : il coerce ("9.90" → Decimal), borne (enum), rend l'absence explicite (optional). Coût : un check au runtime, à la frontière. Steep/RBS agit à l'analyse, sur le code : il attrape la faute de frappe ou le mauvais type avant de lancer, sans coût d'exécution. Graduel : tu n'écris des signatures que là où tu veux la preuve.dry-struct acts at runtime, on the data: it coerces ("9.90" → Decimal), bounds (enum), makes absence explicit (optional). Cost: a runtime check, at the boundary. Steep/RBS acts at analysis, on the code: it catches the typo or wrong type before running, with no runtime cost. Gradual: you write signatures only where you want the proof.

Les deux ne s'opposent pas : dry-struct garde la donnée externe à l'entrée (HTTP, JSON, CSV — ce que les types statiques ne voient pas venir), Steep prouve le code interne que tu choisis d'annoter. La plupart des bases sérieuses utilisent les deux, chacun à sa frontière.The two aren't opposed: dry-struct guards external data at the entry (HTTP, JSON, CSV — what static types can't foresee), Steep proves the internal code you choose to annotate. Most serious codebases use both, each at its boundary.

№ 04Le piègeThe trap4 min

Typer partout, c'est reconstruire le système rigide qu'on venait de fuir.Typing everywhere rebuilds the rigid system you'd just escaped.

Le danger n'est plus le laxisme, c'est l'excès inverse. Soulagé par dry-struct ou par des signatures RBS, on se met à typer chaque objet de valeur interne, à contrôler le type à l'entrée de chaque méthode privée. On a alors reconstruit, à la main et en plus verbeux, le langage à types que les deux premiers numéros avaient désamorcé : la cérémonie revient, le couplage revient, et les fakes de test redeviennent impossibles. La garantie se gagne à la frontière, pas à chaque ligne.The danger is no longer laxity, it's the opposite excess. Relieved by dry-struct or RBS signatures, you start typing every internal value object, checking types at the entry of every private method. You've then rebuilt, by hand and more verbosely, the typed language the first two issues defused: the ceremony returns, the coupling returns, and test fakes become impossible again. The guarantee is earned at the boundary, not at every line.

Le typage interne qui étouffe le domaineInternal typing that smothers the domain
# Le réflexe inverse : rassuré par dry-struct, on en met PARTOUT
class Cents < Dry::Struct
  attribute :value, Types::Strict::Integer
end
class TaxRate < Dry::Struct
  attribute :value, Types::Strict::Decimal
end

def apply_tax(cents, rate)
  raise TypeError unless cents.is_a?(Cents)    # contrôle de type interne
  raise TypeError unless rate.is_a?(TaxRate)   # … à chaque méthode
  Cents.new(value: (cents.value * (1 + rate.value)).round)
end
# on a re-typé tout le domaine : cérémonie, couplage, et adieu les fakes
# de test — exactement le système rigide que ce volume a désamorcé
Typé à la lisière, canard à l'intérieurTyped at the edge, duck within
# La frontière typée, l'intérieur en duck typing
class OrderInput < Dry::Struct           # UNE struct, à l'entrée HTTP
  attribute :cents, Types::Coercible::Integer
  attribute :rate,  Types::Coercible::Decimal
end

def apply_tax(cents, rate)               # à l'intérieur : aucun type exigé
  (cents * (1 + rate)).round            # marche pour Integer, Decimal, un fake…
end

input = OrderInput.new(params)           # validé une fois, à la lisière
apply_tax(input.cents, input.rate)       # le domaine fait confiance
Le contre-exemple obligatoire.The mandatory counter-example.

Dégainer dry-struct pour chaque valeur interne, ou des sig sur tout le domaine, c'est rebâtir le système rigide que le Vol 3 a passé deux numéros à désamorcer — en pire, car fait main. Place le typage là où la donnée entre (HTTP, DB, API, CSV) et là seulement ; à l'intérieur, garde le duck typing, sa souplesse et ses fakes. La règle tient en une image : un mur d'enceinte à la frontière, pas un poste de contrôle à chaque porte intérieure.Reaching for dry-struct on every internal value, or sig across the domain, rebuilds the rigid system Vol 3 spent two issues defusing — worse, because hand-made. Put typing where data enters (HTTP, DB, API, CSV) and only there; inside, keep duck typing, its flexibility and its fakes. The rule fits an image: a perimeter wall at the boundary, not a checkpoint at every interior door.

Une garantie se pose à la frontière du système, une fois. Répétée à chaque méthode, ce n'est plus une garantie — c'est la rigidité qu'on avait fuie.A guarantee is placed at the system boundary, once. Repeated at every method, it's no longer a guarantee — it's the rigidity you fled.
№ 05Bilan & éditoWrap-up & editorial2 min

Une garantie, à la frontière. Du duck typing, partout ailleurs.A guarantee, at the boundary. Duck typing, everywhere else.

Le duck typing déplace l'erreur à l'exécution, loin de sa cause. La réponse n'est pas d'y renoncer, ni de tout vérifier : c'est de choisir où regagner une garantie. À la frontière du système, là où la donnée externe entre. Deux outils, deux endroits — l'un au runtime sur la donnée, l'autre à l'analyse sur le code. Le domaine interne, lui, garde sa souplesse.Duck typing pushes the error to runtime, far from its cause. The answer isn't to abandon it, nor to check everything: it's to choose where to regain a guarantee. At the system boundary, where external data enters. Two tools, two places — one at runtime on the data, the other at analysis on the code. The internal domain keeps its flexibility.

dry-structdry-structSteep + RBSSteep + RBS
QuandWhenÀ l'exécution, à l'entrée.At runtime, at the entry.À l'analyse, avant de lancer.At analysis, before running.
Sur quoiOn whatLa donnée : coercition, bornes, absence explicite.The data: coercion, bounds, explicit absence.Le code : types des arguments et retours.The code: argument and return types.
AttrapeCatchesLa donnée externe malformée (HTTP, JSON, CSV).Malformed external data (HTTP, JSON, CSV).La faute de frappe, le mauvais type, sans exécuter.The typo, the wrong type, without executing.
Le piègeThe trapEn mettre sur chaque valeur interne.Putting it on every internal value.Annoter tout le domaine au lieu des seules coutures.Annotating the whole domain instead of just the seams.
Le duck typing n'a pas de filet — alors tends-en un là où la donnée entre, et nulle part ailleurs.Duck typing has no net — so stretch one where the data enters, and nowhere else.
Mot de l'éditeurFrom the editor

Fin du Vol 3 : faire confiance, mais savoir où.End of Vol 3: trust, but know where.

Trois numéros pour une seule discipline. On a refusé de demander le type et fait confiance au message (№ 1) ; on a nommé les protocoles tacites que cette confiance suppose (№ 2) ; et on a regardé en face le prix de cette liberté — l'erreur tardive — pour décider où, et seulement là, regagner une garantie (№ 3). La leçon n'est pas « le duck typing est dangereux », ni « ajoutez des types ». C'est : la confiance est le défaut, la vérification est une exception qu'on place avec soin — à la lisière, jamais à chaque pas. Un langage à types met le filet partout, par construction ; Ruby te laisse le tendre exactement où la donnée est encore inconnue, et le retirer partout où elle ne l'est plus.Three issues for a single discipline. We refused to ask the type and trusted the message (№ 1); we named the tacit protocols that trust assumes (№ 2); and we faced the price of that freedom — the late error — to decide where, and only there, to regain a guarantee (№ 3). The lesson isn't 'duck typing is dangerous', nor 'add types'. It's: trust is the default, checking is an exception you place with care — at the edge, never at every step. A typed language nets everything by construction; Ruby lets you stretch the net exactly where data is still unknown, and remove it everywhere it no longer is.

Prochain volume : la mutabilité & l'identité. On a passé trois volumes à envoyer des messages à des objets — mais que se passe-t-il quand deux variables désignent le MÊME objet, et que l'une le modifie ? Le Vol 4 ouvre la boîte des bugs d'aliasing : a = b ne copie rien, et muter en place se voit partout où l'objet est tenu.Next volume: mutability & identity. We spent three volumes sending messages to objects — but what happens when two variables name the SAME object, and one mutates it? Vol 4 opens the box of aliasing bugs: a = b copies nothing, and mutating in place shows up everywhere the object is held.