Aujourd’hui c’est Mathieu, CTO de Syadem, qui prend la parole pour vous parler du typage en Ruby !
Temps de lecture : 6 minutes
Hello les petits Biscuits !
Bienvenue sur la 37ème édition de Ruby Biscuit.
Vous êtes maintenant 595 abonnés 🥳
Maintenant Ruby biscuit, c’est aussi votre meilleur allié pour recruter des devs Ruby !
Si vous n’avez pas encore rejoint le club, RDV sur https://recrutement.rubybiscuit.fr
Bonne lecture.
Une brève introduction au typage en Ruby
Ajouter des types à un langage dynamique comme Ruby, mais pourquoi ?
Quand on y réfléchit, c'est une idée moins surprenante qu'il paraît.
Après tout, c'est ce que propose Typescript, avec le succès qu'on connaît aujourd'hui.
Saviez-vous d'ailleurs qu'une majorité de développeurs utilise désormais TypeScript plutôt que JavaScript ? 1
La tendance est claire : l'ajout d'un système de types à un langage dynamique suscite un engouement certain.
Après tout, qui ne voudrait pas d'un monde où undefined method 'machin' for nil:NilClass
ne serait plus qu'un lointain souvenir ?
Notre objectif dans cet article n'est pas de vous donner un cours magistral sur les systèmes de types, mais plutôt de faire un état de l'art de ce que Ruby propose aujourd'hui.
Alors accrochez-vous : on entre sur un terrain expérimental, parfois instable, mais plutôt excitant pour l'avenir de Ruby !
Le rôle central du duck typing
Avant de rentrer dans le vif du sujet avec un exemple de code, il est important de rappeler un pilier de Ruby : le duck typing.
If it walks like a duck and it quacks like a duck, then it must be a duck.
« Duck typing » : une technique qui consiste à ne pas se soucier de la classe exacte d'un objet, du moment qu'il répond aux méthodes attendues.
Magritte n'aurait probablement pas apprécié.
Prenons un exemple.
En Ruby, puts
n'exige pas que l'argument soit une String
. Il suffit que l'objet réponde à la méthode #to_s
. C'est du duck typing pur !
Ce n'est donc pas la classe qui compte, mais l'ensemble des méthodes auxquelles un objet répond.
Autrement dit, son "interface".
Dans un code Ruby classique, ces interfaces sont implicites : on suppose que tel objet a telle méthode, mais rien ne l'indique clairement.
Cela fonctionne très bien... jusqu'au moment où un bug se glisse, parce qu'un objet ne répond pas à la méthode attendue.
Vous voyez déjà où on va en venir : un système de types permet de formaliser ces attentes.
Il ne s'agit plus seulement de supposer qu'un objet a une méthode, mais bien de le déclarer de manière explicite.
Avant de pouvoir formaliser nos interfaces, il nous faut un moyen de les décrire.
Écrire des types avec RBS
Ruby 3.0 a introduit un système de signatures de types appelé RBS (Ruby Signature).
RBS est un langage de description de types qui permet de définir les types des classes, des modules et des méthodes Ruby.
Il permet d'écrire des fichiers de signatures .rbs
contenant les définitions des classes, modules, méthodes, variables d'instance, constantes, etc., ainsi que leurs types.
L'idée est de séparer les annotations de type du code Ruby lui-même : au lieu d'ajouter des annotations en ligne dans les fichiers .rb
, on écrit des fichiers .rbs
à côté.
Un fichier RBS ressemble à du Ruby simplifié, où les corps de méthodes sont remplacés par des signatures de type. Par exemple, si on a la classe Ruby suivante :
class Duck
def quark
puts "Quark"
end
end
On peut écrire la signature correspondante dans un fichier duck.rbs :
class Duck
def quark: () -> void
end
Bien sûr, qui dit système de types dit aussi vérification de types (type checking).
C'est ici qu'entre en jeu steep
.
Un exemple concret avec Steep (et RBS)
Steep est un vérificateur de types (ou type checker), créé par Soutaro Matsumoto, fortement impliqué dans la communauté Ruby 3.
Contrairement à Sorbet, Steep n'introduit aucune annotation dans le code Ruby : il s'appuie uniquement sur les fichiers de signature (RBS) pour analyser le code.
Matz, le créateur de Ruby, en est ravi : son langage reste ainsi préservé !
L'intégration de Steep dans un nouveau projet Ruby est relativement simple.
Steep permet également de s'appuyer sur des définitions de types maintenues par la communauté, comme celles de gem_rbs_collection.
bundle add steep --group=development
bundle exec steep init
La commande steep init
génère un fichier de configuration, le Steepfile
, à la racine du projet.
Dans ce Steepfile
, on spécifie :
quels répertoires de code analyser (généralement
lib/
ouapp/
pour un projet Rails) ;où se trouvent les signatures (par convention, dans
sig/
) ;quelles librairies standards ou gems inclure (Steep sait charger les RBS de la stdlib et de gem_rbs_collection, par exemple).
L'intégration de Steep dans un projet existant semble plus complexe : il faut en effet définir les signatures pour tous les fichiers avant d'obtenir un système fonctionnel.
De plus, rien ne garantit la présence de types pour les gems utilisées dans ce projet même si avec gem_rbs_collection, les principales gems (comme Rails, par exemple) sont bien typées : liste.
Voici un exemple simplifié de Steepfile :
target :app do
check 'app'
signature 'sig'
# je vous recommande cette option qui permet de relever toutes les incohérences de type
configure_code_diagnostics(Steep::Diagnostic::Ruby.all_error)
end
Et le code d'une petite app :
class Fish
def swim
"Le poisson nage dans l'eau."
end
end
class Duck
def speak
"coin coin"
end
end
class Cat
def speak
"meow"
end
end
class SpeakService
def initialize(animals)
@animals = animals
end
def call
animal = @animals.sample
puts "The #{animal.class} says: #{animal.speak}"
end
end
animals = [Duck.new, Cat.new] # le poisson n'est pas dans la liste des animaux
SpeakService.new(animals).call
Cette application fonctionne parfaitement grâce au duck typing !
Mais voilà, des années plus tard, on demande une nouvelle fonctionnalité : ajouter le poisson dans la liste des animaux.
La classe Fish
était déjà présente dans la codebase depuis longtemps, et elle est probablement utilisée ailleurs dans le code.
Naturellement, le développeur chargé d’implémenter cette fonctionnalité ne pensera pas forcément à vérifier si Fish
possède bien toutes les méthodes nécessaires.
# ...
animals = [Duck.new, Cat.new, Fish.new]
SpeakService.new(animals).call
Et là, c'est la catastrophe : l'app plante aléatoirement en production parce que le poisson n'a pas de méthode speak
.
C'est précisément dans ce genre de situation que le typage statique peut nous sauver la mise ! En définissant des contrats clairs pour nos objets, on s'assure qu'ils répondent bien aux méthodes attendues — et on évite ainsi ce type de problème en production.
On aurait pu définir les types ainsi dès le début :
# sig/duck.rbs
class Duck
include _Speakable
end
# sig/cat.rbs
class Cat
include _Speakable
end
# sig/fish.rbs
class Fish
def swim: () -> String
end
# sig/speakable.rbs
interface _Speakable
def speak: () -> String
end
type speakable_object = _Speakable & Object # une astuce pour que les speakables répondent également à #class
# sig/speak_service.rbs
class SpeakService
@animals: Array[speakable_object]
def initialize: (Array[speakable_object] animals) -> void
def call: () -> void
end
Note : Il est possible de générer des types inférés (avec rbs prototype
), par exemple pour générer la signature de la classe Fish
.rbs prototype rb lib/fish.rb > sig/fish.rbs
On lance la commande steep check
(ou mieux, la CI s'en occupe) :
Ce n'est pas très clair, mais on comprend à peu près que Fish ne satisfait pas l'interface _Speakable.
On ajuste donc le type.
class Fish
include _Speakable
def swim: () -> String
end
On peut vérifier que le type est correct en lançant steep check
à nouveau.
Super, les types sont bons ! Mais il reste une erreur sur l'implémentation de la class Fish.
Corrigeons cela en ajoutant une méthode speak
à la class Fish.
class Fish
def swim
"The fish swims gracefully."
end
def speak
"Blub blub"
end
end
Et pour se convaincre que tout marche bien : steep check
.
Plutôt fastidieux, tous ces allers-retours entre la console et le code.
Heureusement, il est possible d'accéder à ces erreurs directement depuis VSCode, grâce aux extensions steep
et ruby-lsp
.
Cerise sur le gâteau, on peut également sauter à la définition des types.
Je vous recommande de le configurer dans VS Code, sinon la navigation entre les fichiers est un peu contraignante.
Encore une dernière astuce pour les utilisateurs de Copilot : il faut reconnaître qu'il est franchement aux fraises concernant RBS.
Le langage est probablement trop récent et les modèles manquent sans doute d'exemples.
Cependant, il suffit d'ajouter un petit lien vers la doc de la syntaxe RBS dans votre .copilot-instructions.md
, et il devient un expert !
Le lien : https://github.com/ruby/rbs/blob/master/docs/syntax.md
Pour conclure
Le potentiel de Steep et RBS pour l’écosystème Ruby est considérable.
Ces outils permettent aux développeurs de détecter et corriger des erreurs potentielles bien avant la production, renforçant ainsi la qualité et la robustesse de leurs applications.
Cependant, Steep n’est pas encore totalement prêt pour une utilisation à grande échelle :
La documentation reste incomplète
Il est difficile de l’adopter progressivement dans un projet existant
Toutes les fonctionnalités de Ruby ne sont pas encore supportées
RBS est encore jeune et susceptible d'évoluer
Malgré ces limites, je recommande vivement de l’expérimenter sur un petit projet : l’usage combiné de Steep et RBS est très prometteur.
L’intégration avec ruby-lsp fonctionne remarquablement bien et rend l’expérience particulièrement agréable.
Pour la première fois en 15 ans de développement Ruby, j’ai écrit une API complète sans aucun problème de typage.
Cette API Rack de prise de rendez-vous (plusieurs centaines de lignes de code) a été développée avec :
des tests unitaires et d’intégration avec
rspec
du typage au runtime avec
dry-struct
et, évidemment, du typage statique avec
steep
Cette application, qui n’avait jamais été exécutée en environnement local, a été déployée dans notre environnement de staging sans la moindre erreur.
Il n’y a eu aucun grain de sable dans les rouages : toutes ses composantes se sont intégrées sans problème 🎉.
Chez Syadem, nous utilisons beaucoup l’injection de dépendances.
Or, il est facile de mal injecter ou d’oublier une dépendance.
Cette fois, Steep m’a rattrapé dès le développement, évitant ainsi ces erreurs particulièrement frustrantes.
— Mathieu, CTO de Syadem
Article intéressant ! Pour celles et ceux qui préfèrent leurs types dans le même fichier que leur code, Sorbet est une alternative, mais assez verbeuse et qui ne se lit pas aussi bien que du Ruby.
Ce post de Shopify sur une alternative pour combiner Sorbet et RBS me semble être une direction intéressante pour le typage statique en Ruby : https://railsatscale.com/2025-04-23-rbs-support-for-sorbet/
La version vidéo à RubyKaigi2025 : https://youtu.be/l4YjoEgpmXs?si=QDAWr0bsagd0PQi1