Imagine que tu demandes à quelqu’un de te construire une étagère. Il te la donne. Elle est belle. Elle a des étagères, des vis, tout est à sa place. Tu la poses contre le mur et elle s’effondre. Les vis sont des accessoires. Elles ressemblent à des vis, mais sont en plastique.
C’est ce qu’un LLM fait lorsqu’il abuse du système de types. Il te livre du code qui compile, qui passe les tests, qui a la forme correcte. Mais en interne, là où il devrait y avoir des types significatifs, il y a des chaînes de caractères (String). Là où il devrait y avoir un état explicite, il y a un nil qui signifie trois choses différentes selon la personne qui le lit. Et là où il devrait y avoir un énumération (enum) avec deux cas, il y a un == "claude" qu’un jour quelqu’un écrira mal, et personne ne s’en rendra compte avant que cela ne tombe en production.
Le raccourci préféré du modèle : la chaîne universelle
J’ai passé des mois à collaborer avec une IA sur le développement d’une application Swift de taille moyenne. Le code généré est propre, bien structuré, avec de bons noms de variables. Et pourtant, il y a un schéma qui revient encore et encore : le modèle évite de créer de nouveaux types.
Ce n’est pas par malice. C’est parce que créer un nouveau type exige de prendre des décisions de design : est-ce un enum ? Combien de cas doit-il avoir ? Où doit-il résider ? Qui doit l’importer ? Une chaîne de caractères (String) ne demande aucune de ces décisions. Elle s’adapte partout. Elle compile toujours.
Le résultat est un code comme celui-ci :
func sessions(harness: String? = nil) -> [Session] {
if let harness {
return all.filter { $0.harness == harness }
}
return all
}
// Utilisation :
let claudeSessions = sessions(harness: "claude")
let codexSessions = sessions(harness: "codex")
---
Cela paraît correct. Ça fonctionne. Mais il y a trois problèmes dissimulés :
1. **Personne ne t'avertit si tu écris `"cladue"`**. Une chaîne de caractères accepte n'importe quoi. Un _enum_ ne le fait pas.
2. **`nil` signifie "tous"**, mais cela ne se voit dans aucun type. C'est une convention qui existe uniquement dans ta tête et peut-être dans un commentaire que personne ne lira.
3. **Chaque fonction qui filtre par _harness_ répète le même schéma** de `String? = nil`. Si demain tu ajoutes un troisième _harness_, tu devras parcourir toutes les comparaisons de chaînes éparpillées dans le code.
En d'autres termes : le compilateur ne peut pas t'aider car tu lui as retiré toutes les informations sémantiques. Tu lui as donné une chaîne de caractères là où il y avait un concept métier.
## La version que le modèle aurait dû écrire
```swift
enum HarnessID: String, Codable {
case claude
case codex
}
enum HarnessFilter {
case all
case specific(HarnessID)
}
func sessions(harness: HarnessFilter = .all) -> [Session] {
switch harness {
case .all:
return all
case .specific(let id):
return all.filter { $0.harnessID == id }
}
}
// Utilisation :
let claudeSessions = sessions(harness: .specific(.claude))
let allSessions = sessions() // .all par défaut — explicite
Maintenant, "cladue" ne compile pas. “Tous” n’est pas un nil magique mais un cas explicite de l’énumération. Et si tu ajoutes un troisième harness, le compilateur t’oblige à gérer le nouveau cas dans chaque switch. Il transforme les erreurs d’exécution (runtime) en erreurs de compilation. C’est ce que devrait faire un système de types.
Le nil comme tiroir fourre-tout
Le deuxième raccourci favori est d’utiliser nil pour représenter quelque chose qui a une signification propre. Le modèle adore les champs optionnels. Et cela a du sens : un champ optionnel est le moyen le plus rapide d’ajouter une donnée sans tout casser. Mais il y a une énorme différence entre “cette valeur peut ne pas exister” et “cette valeur a un état spécifique que je codifie comme une absence”.
Exemple réel :
var calibrationDate: Date? // nil = jamais calibré
var quotaPercent: Double? // nil = inconnu
var errorMessage: String? // nil = pas d'erreur
Trois champs, trois significations différentes de nil. Le premier est légitime : il se peut qu’il n’ait jamais été calibré. Le deuxième est douteux : “inconnu” est un état qui mérite son propre type. Le troisième est dangereux : tu utilises l’absence d’erreur pour représenter le succès et la présence d’une chaîne de caractères pour représenter un échec. Cela revient à utiliser un Result caché sous forme d’optionnel.
Ce que voit le compilateur dans les trois cas est identique : Optional<T>. Il ne peut pas distinguer “l’absence légitime” de “j’utilise nil comme drapeau booléen”. Et si le compilateur ne le voit pas, toi non plus, lorsque tu liras le code six mois après.
Pourquoi le LLM fait ça
Ce n’est pas de la paresse. C’est une optimisation pour satisfaire de mauvais objectifs.
Le modèle est entraîné à produire du code qui compile et passe les tests. Une chaîne de caractères compile systématiquement. Un nil compile toujours. Une nouvelle énumération nécessite de la définir, de l’importer et de mettre à jour tous les points d’appel. Du point de vue du modèle, une chaîne de caractères présente moins de difficultés et donne le même résultat immédiat.
C’est exactement comme un débutant qui utilise any dans TypeScript. Ce n’est pas qu’il ne sait pas qu’il existe de meilleurs types. C’est que any compile et la tâche est bouclée. Les incitations sont mal alignées.
| Ce que le modèle optimise | Ce dont tu as besoin |
|---|---|
| Compiler du premier coup | Que le compilateur te protège |
| Passer les tests existants | Que les nouveaux bugs soient impossibles |
| Tolérer le moindre changement | Maximiser les informations sémantiques |
| Résoudre le problème actuel | Ne pas créer des soucis pour demain |
Première défense : un linter de types
Quand j’ai compris que le modèle répétait ces schémas, ma première réaction a été la même qu’avant : écrire des règles dans le CLAUDE.md. “NE PAS utiliser String là où une énumération est nécessaire”. “NE PAS utiliser nil comme drapeau”.
On connaît la suite. Le modèle lit la règle, acquiesce, et après quelques générations, il réintroduit un harness: String? = nil. Pas par rébellion — mais parce qu’une chaîne offre la voie de la moindre résistance et que le contexte de 200K tokens a fait oublier ta règle.
Alors j’ai écrit un linter. Un script Bash qui détecte des schémas suspects dans le code source :
# T4: Comparaison littérale avec des valeurs qui devraient être des enums
fail_if_found "T4" "ERROR" \
'Comparaison == "claude" / == "codex" (devrait être == .claude)' \
'==\s*"(claude|codex)"'
# T7: nil comme "tous" dans les paramètres de filtre
fail_if_found "T7" "ERROR" \
'Paramètre harness: String? = nil (devrait être HarnessFilter)' \
'harness:\s*String\?\s*='
# T1: ExpressibleByStringLiteral dans les types métier
fail_if_found "T1" "ERROR" \
'ExpressibleByStringLiteral réintroduit stringly-typed' \
'ExpressibleByStringLiteral'
Huit règles au total. Chaque règle recherche un schéma spécifique avec grep. Si celui-ci est trouvé, il provoque une erreur. Pas d’interprétation, pas de jugement, pas de “dans ce cas, ça passe”. Si le schéma est détecté, l’intégration continue échoue.
Non, modèle. Ce == "claude" ne passera pas.
Le linter est délibérément basique. Il n’analyse pas les arbres syntaxiques, ne comprend pas le contexte et n’utilise pas d’heuristiques sophistiquées. Il cherche du texte. Et c’est un avantage : il est impossible à contourner. Le modèle ne peut pas rationaliser une exception si la détection est purement textuelle.
Deuxième défense : une compétence d’audit
Le linter détecte les symptômes. Mais les symptômes découlent d’un problème plus profond : le modèle ne prend pas le temps de réfléchir au type qu’il devrait utiliser.
Pour cela, j’ai créé une compétence d’audit — un fichier Markdown que l’agent exécute sur demande et qui passe systématiquement en revue l’utilisation des types dans n’importe quel module. La compétence recherche :
- Chaînes de caractères représentant des ensembles finis. Si un champ ne peut contenir que trois valeurs connues, il devrait être une énumération (enum), pas une chaîne de caractères.
- Optionnels représentant des états. Si
nilsignifie quelque chose de spécifique (« inconnu », « non applicable », « tous »), il devrait être un cas dans une énumération. - Dictionnaires avec des clés en chaînes de caractères où la clé est un concept métier.
[String: Session]devrait être[HarnessID: Session]. setValue(forKey:)et ses semblables qui contournent le système de types de Core Data.Dictionary(uniqueKeysWithValues:)sans garantie que les clés sont uniques — risque de crash au runtime.
La compétence n’est pas magique. C’est une checklist qui force le modèle à passer au crible le code avec un focus précis. La différence entre « révise les types » (vague) et « identifie les champs String qui n’acceptent que N valeurs connues » (spécifique) est la différence entre un audit utile et un « tout semble OK ».
Avant et après
Après l’application du linter et de la compétence d’audit à un module en développement depuis deux mois :
| Problème détecté | Quantité | Gravité |
|---|---|---|
Comparaisons == "string literal" | 4 | Erreur : typo invisible au runtime |
Paramètres String? = nil comme filtres | 3 | Erreur : nil sémantique masqué |
Champs String déguisant des enums | 2 | Refactorisation : perte de sécurité des types |
setValue(forKey:) dans Core Data | 1 | Erreur : le compilateur ne valide pas la clé |
Dix problèmes. Tous introduits par le modèle. Aucun détecté par les tests existants, car les tests utilisaient les mêmes chaînes que le code. Fiction validant une autre fiction, encore une fois.
Après refactorisation, le code comptait 40 lignes supplémentaires (avec la définition des énumérations) et aucune chaîne isolée. Chaque comparaison passait par le compilateur. Chaque filtre avait un type explicite. Et le prochain bug dû au modèle qui écrit accidentellement "cladue" devenait impossible.
Ce que tu peux faire aujourd’hui dans ton projet
Tu n’as pas besoin de travailler en Swift. Tu n’as pas besoin de mon linter. Ce schéma s’applique à n’importe quel langage avec un système de types :
En TypeScript :
// Avant : stringly-typed
function getUsers(role: string = "all") { ... }
getUsers("adnin") // typo, compile, échoue au runtime
// Après : type-safe
type Role = "admin" | "viewer" | "billing"
type RoleFilter = Role | "all"
function getUsers(role: RoleFilter = "all") { ... }
getUsers("adnin") // erreur de compilation
En Python avec Enum :
# Avant
def process(status: str | None = None): ...
process("pending") # "pending" ou "Pending" ou "PENDING" ?
# Après
class Status(Enum):
PENDING = "pending"
DONE = "done"
def process(status: Status | None = None): ...
Règle générale : si une valeur ne peut prendre que N options connues, elle doit être un type avec N cas, pas une chaîne avec des possibilités infinies. Si nil signifie quelque chose d’autre qu’« absent », cela mérite un nom spécifique.
Et si tu travailles avec un agent IA, configure un linter qui détecte les schémas suspects. Il n’a pas besoin d’être sophistiqué. Un grep avec les schémas propres à ton domaine suffit. L’important est qu’il s’exécute en CI, automatiquement, et qu’il ne dépende pas du fait que le modèle ait lu ton README.
Le méta-problème
Paradoxalement, nous demandons à un modèle de langage — un système qui fonctionne fondamentalement avec des chaînes de caractères — d’arrêter d’utiliser des chaînes là où ce n’est pas approprié.
C’est comme demander à un menuisier d’arrêter d’utiliser du bois. Bien sûr, il peut le faire, mais son instinct le pousse dans cette direction. La chaîne de caractères est le type natif du LLM. Tout ce qu’il voit est du texte. Tout ce qu’il génère est du texte. Lorsqu’il doit choisir entre un type riche en sémantique et une chaîne « qui fonctionne », la chaîne gagne par défaut.
La solution n’est pas de lui demander de changer. C’est de s’attaquer au problème avec des outils qui détectent automatiquement ces schémas et les bloquent avant qu’ils n’atteignent la branche principale. Un linter bête est plus efficace qu’un modèle intelligent s’il s’exécute toujours et si le modèle oublie les règles occasionnellement.
Tes types sont ta documentation vivante. Si tu les réduis à des chaînes de caractères, ton code compile mais ne communique rien. Et la prochaine personne qui le lira — que ce soit toi, un collègue, ou un autre modèle — devra deviner ce que ce nil voulait dire.
Élimine toute incertitude. Rends la voie incorrecte incompilable.