Étude de cas : l'énoncé et les données
Tout ce que tu as lu depuis le début du guide converge ici. On prend un énoncé de test technique réel — un vrai sujet de stage frontend, anonymisé — et on fait ce que presque personne ne fait : on l'ouvre comme un professionnel et on lit les fichiers de données avant d'écrire la moindre ligne. Trois découvertes sortent de cette lecture, et elles décident de toute l'architecture. Le module suivant, Étude de cas : la solution disséquée, écrit le code et apprend à le raconter à l'oral.
Un test technique n'évalue pas ta vitesse de frappe : il évalue ta capacité à transformer un énoncé flou en décisions défendables. Les vingt premières minutes — celles où tu ne codes pas — pèsent souvent plus lourd que les quatre-vingt-dix suivantes.
À quoi ressemble un test technique de stage
Le format s'est standardisé. On t'envoie un dépôt squelette : un projet déjà
initialisé (framework, TypeScript, linter, parfois un fichier de test vide), quelques
fichiers de données, et un README qui liste ce qu'on attend. Une durée
indicative — deux heures, le plus souvent —, puis tu rends le code et tu
viens le présenter à l'oral pendant vingt à trente minutes.
Cette deuxième moitié est la partie que tout le monde sous-estime : c'est là que l'équipe décide, parce que c'est le seul moment où elle peut vérifier ce qui l'intéresse vraiment — non pas « ce candidat sait-il produire du code », mais « ce candidat comprend-il le code qu'il produit ? ». C'est la frontière exacte entre un junior qu'on peut faire progresser et un junior qui reproduit des recettes.
Ce qui est réellement noté n'est ni le nombre de fonctionnalités, ni l'esthétique, ni l'astuce technique. Dans à peu près tous les barèmes, on retrouve les mêmes trois questions, dans cet ordre :
- Est-ce que ça marche ? Cloner, lancer une commande, faire le parcours principal du début à la fin sans erreur. C'est éliminatoire, et plus fréquent qu'on ne croit : un projet qui ne démarre pas est écarté avant même la lecture du code.
- Est-ce que c'est lisible ? En ouvrant trois fichiers au hasard, je comprends l'architecture ? Les noms disent-ils ce qu'ils font ? Y a-t-il du copié-collé, une fonction de deux cents lignes ? C'est là qu'un candidat qui a fait moins mais proprement passe devant un candidat qui a tout tenté.
- Est-ce que tu peux l'expliquer ? Pourquoi ce découpage ? Pourquoi cet état vit-il ici ? Qu'est-ce qui casse si je change telle donnée ? Ça ne se prépare pas la veille : ça se prépare pendant que tu codes.
Retiens la hiérarchie, elle dicte la stratégie du reste du module : un projet minimal, propre et parfaitement expliqué bat systématiquement un projet ambitieux, brouillon et mal maîtrisé. Une équipe recrute quelqu'un avec qui elle va relire du code pendant deux ans ; elle choisit en fonction de ça.
« Deux heures » n'est presque jamais un chronomètre : c'est une indication de
périmètre, donc le message caché est « nous ne nous attendons pas à ce que tout
soit fait ». Rendre des exigences de base solides en deux heures, avec un
README qui explique ce qui a été laissé de côté et pourquoi, respecte
parfaitement la consigne. Passer huit heures pour tout livrer envoie un signal ambigu :
soit tu ne sais pas estimer, soit tu ne sais pas t'arrêter.
L'énoncé, tel quel
Lis-le une première fois normalement, comme tu le lirais le jour J ; on le relira ensuite ligne par ligne pour en extraire ce qu'il ne dit pas explicitement.
Ce sujet est un vrai test technique de stage frontend, reconstitué fidèlement et anonymisé. Les fichiers de données plus bas sont ceux du test, à l'identique. C'est précieux : les exercices d'entraînement qu'on trouve en ligne sont artificiellement propres, alors qu'un vrai énoncé contient toujours des zones d'ombre, des données bancales et des priorités implicites. C'est ça qu'on va apprendre à lire.
Le contexte
Construire une petite application de prise de rendez-vous pour une clinique d'imagerie médicale. La particularité : les formulaires ne sont pas écrits en dur, ils sont pilotés par une configuration JSON. Selon le service que l'utilisateur choisit, ce n'est pas le même formulaire qui s'affiche.
Le stack imposé
Next.js, TypeScript, Vitest, et CSS vanilla — pas de Tailwind, pas de
CSS-in-JS. Cette contrainte n'est pas un caprice : elle sert à voir si tu sais écrire du CSS
sans framework, ce que les modules CSS 1 et CSS 2 t'ont préparé à faire. Le stack imposé est
déjà une information : on te demande du Next.js, donc on attend que tu utilises ce que
Next.js apporte, pas du React générique dans un dossier app/.
Les fichiers de données fournis
data/services.json— la liste des services offerts par la clinique.-
data/form.json— les configurations de formulaire, chacune associée à une liste de services. La valeur spéciale"*"sert de configuration de repli pour tout service qui n'a pas de configuration dédiée.
Les cinq exigences
- Une liste déroulante pour choisir un service, qui conditionne l'accès au formulaire.
- Affichage conditionnel du formulaire selon le service choisi.
- Champs générés dynamiquement depuis
data/form.json. - Un
console.logde toutes les valeurs à la soumission. - Réinitialisation du formulaire après une soumission réussie.
Les six bonus, par ordre de priorité annoncé
- Remplacer les imports JSON locaux par des routes d'API Next.js.
- Un CRUD de gestion des services (stockage en mémoire).
- Pré-sélection du service depuis l'URL (par exemple
/bone-scan). - Persistance de l'état du formulaire entre les rechargements de page.
- Historique des soumissions, avec stockage côté serveur.
- Fonctionnalités libres.
Tu as reconnu le décor : le fil rouge du guide, « RendezVous », est une clinique avec un formulaire de réservation. Pas un hasard — il a été construit pour te préparer à ce genre d'énoncé. Le formulaire piloté par configuration croisé à la fin de Formulaires 1, et sa version validée génériquement dans Formulaires 2, sont les deux briques centrales de la solution qui suit. Si ces modules te semblent loin, c'est le bon moment pour les relire : celui-ci les assemble plus qu'il ne les réexplique.
L'enquête — lire l'énoncé comme un professionnel
Le module change de genre ici : on arrête de décrire le format, on mène l'enquête sur ce sujet précis. Et voici la chose la plus contre-intuitive du module : le jour du test, tu ne dois pas commencer par coder. Tu commences par une phase de lecture d'environ vingt minutes, l'éditeur ouvert uniquement pour regarder. Ces vingt minutes ne produisent aucune ligne de code et pourtant elles déterminent la note.
1. Inventorier ce qu'on t'a donné
Parcours l'arborescence du dépôt en entier, pas en diagonale. Tu cherches quatre choses.
Quels fichiers de données sont fournis ? Quelle configuration
est déjà en place (tsconfig.json, next.config, un alias
@/, un fichier de test d'exemple) ? Quelles dépendances sont
installées — une dépendance présente dans le package.json est une autorisation
implicite, parfois une invitation ? Y a-t-il du code de départ qu'on attend
que tu complètes plutôt que de remplacer ?
Cinq minutes, et deux catastrophes évitées : réécrire à la main ce qui était déjà fourni, et passer à côté d'une convention du dépôt que l'évaluateur s'attendait à te voir suivre.
2. Ouvrir les données. Vraiment les ouvrir.
L'étape décisive, et celle qu'on saute le plus souvent parce qu'un fichier JSON a l'air ennuyeux. Erreur : dans un exercice piloté par des données, toute la difficulté est dans les données. L'énoncé décrit une intention ; les données décrivent la réalité, et c'est la réalité qu'il faut coder.
Ouvre chaque fichier et pose-toi mécaniquement ces questions : quelles sont les clés, et laquelle est facultative ? Quelles valeurs possibles pour un champ qui ressemble à un type ? Y a-t-il des valeurs spéciales, des sentinelles, des jokers ? Tous les éléments ont-ils la même forme ? Et surtout, la question qui rapporte le plus : y a-t-il des cas non couverts — des entrées d'un fichier sans correspondance dans l'autre ?
3. Hiérarchiser sans état d'âme
L'énoncé te donne une hiérarchie et tu la respectes à la lettre : les cinq exigences d'abord, entièrement, avant le moindre bonus. Pas « quasiment ». Les cinq. La raison est arithmétique : les exigences sont notées en tout-ou-rien — une exigence à moitié faite vaut zéro et donne l'impression que tu n'as pas lu l'énoncé — alors qu'un bonus absent ne coûte presque rien si tu peux expliquer pourquoi tu l'as écarté. L'asymétrie est totale, et pourtant l'immense majorité des candidats se précipite sur les bonus parce qu'ils sont plus amusants.
4. Décider ce que tu ne feras pas
Le geste le plus professionnel de la journée, et personne ne le fait. Avant de commencer,
écris — idéalement dans le README — la liste de ce que tu as
choisi de ne pas faire, avec une raison en une ligne. « Pas de CRUD de
services : le bonus le plus coûteux en interface et le moins lié au cœur de l'exercice. »
« Pas de persistance : elle ajoute une source de vérité concurrente à l'état React. »
Cette liste transforme une absence en décision. Sans elle, l'évaluateur voit un candidat qui n'a pas eu le temps ; avec elle, un candidat qui a arbitré. Et elle te donne, à l'oral, des réponses toutes prêtes à « qu'est-ce que tu aurais fait avec plus de temps ? ».
Le scénario se répète à l'identique. On lit l'énoncé en diagonale, on voit « formulaire dynamique depuis un JSON », on connaît le patron, on écrit trente minutes de code confiant. Puis on branche les vraies données : les types ne correspondent pas, une clé imaginée n'existe pas, une valeur bizarre traverse tout le code. On passe le reste du temps à rattraper une architecture bâtie sur des données imaginaires.
Le coût réel n'est pas les trente minutes perdues : c'est que les corrections successives
laissent des cicatrices — un if spécial ici, une valeur par défaut douteuse
là — et que ce sont exactement elles que l'évaluateur pointera à l'oral. Dix
minutes de lecture des données valent une heure de code.
Tu as trente minutes de retard sur ton plan. Il reste l'exigence 5 (vider le formulaire après un envoi réussi) et le bonus 1 (les routes d'API), à peu près le même temps chacun. Lequel fais-tu, et qu'écris-tu pour l'autre ?
L'exigence 5, sans hésiter une seconde. L'asymétrie est totale : une exigence manquante est une consigne écrite non suivie, elle ne se lit pas « il a manqué de temps » mais « il n'a pas lu ou pas priorisé », et elle coûte le même prix qu'elle soit ratée de peu ou pas commencée. Un bonus absent, lui, ne dit rien de négatif — à une condition : qu'il soit nommé.
Pour l'autre, une ligne dans le README, précise : « Bonus 1 non fait : j'ai préféré terminer les cinq exigences. Concrètement, c'est un app/api/services/route.ts qui réexpose la même fonction de lecture des données, une quinzaine de minutes. » Trente secondes d'écriture qui transforment une absence en arbitrage — et qui te donnent, à l'oral, une réponse déjà prête à « qu'aurais-tu fait avec plus de temps ? ».
Ce que les données t'apprennent
Appliquons la méthode. Voici les deux fichiers tels qu'ils sont fournis : lis-les pour de vrai avant de continuer, trois découvertes s'y cachent et chacune change la structure du code.
[
"Bone Scan",
"Dental Imaging",
"Ultrasound",
"Gallium Scintigraphy",
"Radiography (X-Ray)",
"Computed Tomography (CT-Scan)",
"Virtual Colonoscopy"
]
Sept services. Un tableau de chaînes brutes — pas d'objets, pas d'identifiants : la chaîne est l'identifiant. Retiens-le, ça aura des conséquences.
[
{
"title": "Dental radiology appointment",
"services": ["Dental Imaging"],
"fields": [
{ "name": "first_name", "label": "First Name", "type": "text" },
{ "name": "last_name", "label": "Last Name", "type": "text" },
{ "name": "email", "label": "Email", "type": "email" },
{ "name": "phone_number", "label": "Phone Number", "type": "phone" },
{ "name": "country", "label": "Country", "type": "dropdown",
"options": ["Canada", "USA"] }
]
},
{
"title": "Radiology appointment",
"services": ["Ultrasound", "Gallium Scintigraphy", "Radiography (X-Ray)",
"Computed Tomography (CT-Scan)", "Virtual Colonoscopy"],
"fields": [
{ "name": "first_name", "label": "First Name", "type": "text" },
{ "name": "last_name", "label": "Last Name", "type": "text" },
{ "name": "email", "label": "Email", "type": "email" },
{ "name": "phone_number", "label": "Phone Number", "type": "phone" },
{ "name": "details", "label": "Details", "type": "textarea" }
]
},
{
"title": "Appointment",
"services": ["*"],
"fields": [
{ "name": "first_name", "label": "First Name", "type": "text" },
{ "name": "last_name", "label": "Last Name", "type": "text" }
]
}
]
Trois configurations pour sept services. L'écart entre ces deux nombres est le cœur de l'exercice.
Découverte n° 1 — les type du JSON ne sont pas des types HTML
Regarde les valeurs de type : text, email,
phone, dropdown, textarea. Les deux premières sont des
valeurs légales de l'attribut type d'un <input>. Les trois
autres, non :
| Dans le JSON | Ce qu'il faut produire | Pourquoi |
|---|---|---|
"text" | <input type="text"> | Correspondance directe. |
"email" | <input type="email"> | Correspondance directe. |
"phone" | <input type="tel"> | phone n'existe pas en HTML ; le type téléphone s'appelle tel. |
"dropdown" | <select> + <option> | Ce n'est pas un <input> du tout : c'est un autre élément. |
"textarea" | <textarea> | Idem : un élément à part entière. |
Tu dois donc écrire un petit traducteur entre le vocabulaire de la
configuration et celui du HTML. Découverte à haute valeur : le candidat qui ne l'a pas faite
écrit <input type={field.type} />, ce qui « fonctionne » à l'écran — et
c'est bien là le problème, on y revient dans un exercice plus bas.
Leçon générale : dès qu'une donnée externe contient un champ appelé type,
kind ou variant, ne suppose jamais qu'il parle la même langue que
ta plateforme. Le fournisseur a nommé les choses selon son domaine ; la traduction
vers le tien est ton travail, et elle mérite un endroit clairement identifié plutôt que
d'être éparpillée.
Découverte n° 2 — le repli "*" impose un ordre de recherche
La troisième configuration a "services": ["*"] : c'est le repli. Traduisons-le
en algorithme, parce que la formulation naïve contient un piège. Si tu écris une seule
recherche du genre « trouve la configuration dont la liste contient le service choisi
ou l'étoile », le résultat dépend entièrement de l'ordre des éléments dans
le fichier. Tant que la configuration joker est la dernière — le cas ici — ça marche
par accident ; le jour où quelqu'un réordonne le fichier, tous les services basculent sur le
formulaire générique. Ton code aura l'air correct et sera devenu faux sans qu'une ligne ait
changé.
La formulation robuste dit exactement ce que dit la règle métier : on cherche d'abord une correspondance exacte ; si et seulement si on n'en trouve pas, on cherche le joker. Deux recherches successives, dans cet ordre, et l'ordre du fichier n'a plus aucune influence.
Le repli qui ne ment pas
function findFormConfig(configs, service) {
return configs.find((c) => c.services.includes(service))
?? configs.find((c) => c.services.includes('*'));
}
La correspondance exacte d'abord, le joker ensuite : la priorité est écrite dans la structure du code, pas dans l'ordre des données. Et le type de retour admet l'absence (les deux recherches peuvent échouer), ce qui oblige l'appelant à traiter ce cas — c'est précisément ce qui empêche un plantage à l'exécution le jour où un service n'a ni configuration dédiée ni repli.
Découverte n° 3 — deux services n'ont aucune configuration
Compte : la première configuration couvre Dental Imaging, la deuxième cinq
services. Six. Or services.json en contient sept :
Bone Scan n'apparaît nulle part dans form.json.
Ce n'est pas une erreur du fichier, c'est le test lui-même. Le service orphelin vérifie que ton repli fonctionne, et il est placé volontairement en première position : le tout premier service qu'un évaluateur essaiera d'un clic est celui qui passe par le chemin de repli. Si tu ne l'as pas géré, la démonstration s'écroule à la première interaction.
Conséquence architecturale immédiate : le formulaire générique n'est pas un cas exotique de
fin de liste, c'est un chemin de première classe. Il doit s'afficher avec le
titre "Appointment", ses deux champs, et fonctionner comme les autres —
soumission, journalisation, réinitialisation comprises.
Ce patron, tu le pratiques déjà. Dans ton application mobile, l'écran qui liste les exercices n'a pas une carte écrite en dur par exercice : il lit un catalogue et rend ce qu'il y trouve. Les gabarits de programme décrivent en données une séance que l'interface se contente de dérouler. Le principe est identique : l'interface est une fonction de la donnée, et le code ne connaît pas à l'avance ce qu'il va afficher.
La conséquence est la même des deux côtés : quand l'interface est pilotée par des données,
tes bugs migrent du code vers les données. Un champ mal typé dans un JSON,
une catégorie d'exercice sans correspondance dans ton dictionnaire de traduction, et
l'écran ne plante pas — il affiche quelque chose de subtilement faux. D'où le
réflexe : chaque fois qu'on traduit une valeur de donnée en élément d'interface, se
demander « et si la valeur n'est dans aucun cas prévu ? » et écrire ce cas explicitement.
Le repli "*" est exactement cette question, posée par l'énoncé.
Un candidat a écrit sa fonction de recherche comme ceci. Il a testé avec
Bone Scan, le premier service de la liste, et le formulaire générique
« Appointment » s'est affiché correctement. Il est passé à autre chose.
function findFormConfig(configs, service) {
return configs.find((c) => c.services.includes('*'))
?? configs.find((c) => c.services.includes(service));
}
Questions : (1) Quels services reçoivent le mauvais formulaire ? (2) Pourquoi le bug est-il resté invisible au premier essai ? (3) Que verra exactement l'évaluateur pendant la démonstration ?
Voir le corrigé
(1) Tous. La première recherche porte sur le joker, et le joker existe :
elle réussit toujours. Le ?? ne s'exécute que si son membre de
gauche vaut null ou undefined, ce qui n'arrive jamais. La
seconde recherche, celle qui contient la vraie logique métier, est du code mort : les
sept services reçoivent le formulaire « Appointment » à deux champs.
(2) Parce que le candidat a testé avec le seul service pour lequel ce
comportement est correct : Bone Scan doit tomber sur le repli. Le
résultat observé était exactement le résultat attendu, et le test manuel a « validé » un
code faux. Mécanisme classique du faux positif : on ne teste pas le cas qui distingue les
deux implémentations. Ici, le cas discriminant s'appelle Dental Imaging, et
il aurait suffi d'un clic dessus.
(3) Un formulaire à deux champs pour un examen dentaire, sans téléphone ni pays, et un titre « Appointment » alors que le JSON contient « Dental radiology appointment ». L'exigence centrale — générer le formulaire correspondant au service — n'est pas remplie, alors même que le code a l'air de la remplir.
Bonus, plus subtil. Une autre variante circule beaucoup :
configs.find((c) => c.services.includes('*') || c.services.includes(service)).
Celle-là fonctionne avec le fichier fourni, parce que find teste le
prédicat élément par élément et rencontre la configuration dentaire avant la
joker, qui est en dernière position. Le résultat correct vient de l'ordre du fichier, pas
de la logique : déplace la configuration "*" en tête de
form.json et tout casse. C'est le genre de fragilité qu'un évaluateur adore
mettre en évidence — il ouvre le JSON, déplace un bloc, recharge, et te demande
d'expliquer.
Le fichier des services ne contient que des chaînes brutes, sans identifiant. Cite deux conséquences concrètes sur le code que tu vas écrire.
[
"Bone Scan",
"Radiography (X-Ray)",
"Computed Tomography (CT-Scan)"
]
Un : la chaîne devient la clé, partout. C'est elle qui sert de value dans la liste déroulante, de key de chaque option, de valeur cherchée dans le tableau services de chaque configuration, et de champ journalisé à la soumission. Rien ne relie ces usages entre eux : renomme « Bone Scan » en « Bone scan » dans un seul des deux fichiers et la correspondance échoue en silence — pas d'erreur, juste le formulaire de repli à la place du bon.
Deux : ça ne rentre pas dans une URL. Espaces, majuscules, parenthèses, tirets : "Radiography (X-Ray)" ne peut pas devenir un segment d'adresse tel quel. Dès que l'énoncé demandera de pré-sélectionner un service depuis l'URL, il faudra fabriquer une forme simplifiée — et comme cette transformation perd de l'information, il n'existera pas de fonction inverse fiable : on ne « dé-transformera » pas, on appliquera la même transformation aux deux côtés et on comparera.
Le réflexe général : quand une donnée externe n'a pas d'identifiant stable, note-le tout de suite. Ce n'est pas un détail de style, c'est ce qui décidera de la robustesse de la moitié de ton code.
La dissection — l'architecture retenue, et pourquoi
Fin de l'enquête, début de la dissection : les quatre sections qui suivent ouvrent le code pièce par pièce. On peut maintenant décider de la structure. L'objectif n'est pas d'être malin : c'est que quelqu'un qui ouvre le dépôt comprenne le découpage en trente secondes, et que chaque fichier ait une seule raison d'exister.
app/
layout.tsx racine HTML + import du CSS global
page.tsx route "/" — Server Component
[service]/page.tsx route "/bone-scan" — Server Component (bonus 3)
api/
services/route.ts GET : la liste des services (bonus 1)
form-configs/route.ts GET : les configurations (bonus 1)
submissions/route.ts GET + POST : l'historique (bonus 5)
globals.css
components/
PriseDeRendezVous.tsx 'use client' — la frontière client, possede le service choisi
ServiceSelector.tsx la liste deroulante, purement presentationnelle
DynamicForm.tsx possede les values, la validation, la soumission
Field.tsx traduit UN champ de config en element HTML
lib/
types.ts Field, FormConfig, FormValues
data.ts lecture des JSON — la SEULE porte d'entree des data
configuration.ts findFormConfig + slugs d'URL
validation.ts le validateur pur, generique
data/
services.json fournis par l'enonce, non modifies
form.json
Quatorze fichiers. Chacun tient en moins de quatre-vingts lignes, et son nom dit ce qu'il fait.
Le stack impose Vitest. Sans ouvrir un seul de ces fichiers, dis lesquels tu pourrais tester en une minute — et pourquoi les autres coûtent bien plus cher.
components/
PriseDeRendezVous.tsx la frontière client, possède le service choisi
ServiceSelector.tsx la liste déroulante
DynamicForm.tsx possède les valeurs saisies
Field.tsx traduit UN champ de config en élément HTML
lib/
types.ts data.ts configuration.ts validation.ts
Les fichiers de lib/, et en particulier configuration.ts et validation.ts. Ils prennent des données et rendent des données : pas de React, pas de DOM, pas de doublure à installer. Trois cas pour la recherche de configuration — correspondance exacte, repli, aucun des deux — et trois pour le validateur : cinq minutes de rédaction, quelques millisecondes d'exécution.
Les composants coûtent bien plus : pour atteindre trois lignes de logique il faut monter un rendu, simuler des clics, attendre, puis interroger le DOM — et le test casse au moindre changement d'affichage. Ce qui donne la lecture la plus utile de cette arborescence : le découpage par raison de changer produit exactement le même trait que le découpage par coût de test. Ce n'est pas une coïncidence — c'est pour ça qu'on sort la logique des composants, et c'est la phrase à dire si on te demande « comment tu testerais ça ? » avec dix minutes au compteur.
Le découpage en composants, et la frontière client
La chaîne est une cascade à quatre étages, et chaque étage a une responsabilité unique :
- La page (Server Component) charge les données et ne fait rien d'autre. Elle n'a aucun état, aucun gestionnaire d'événement, et son code ne part jamais dans le navigateur.
-
PriseDeRendezVous(Client Component) est la frontière. C'est elle qui porte le'use client', qui détient le service sélectionné, et qui décide quoi afficher. -
DynamicFormdétient les valeurs saisies, les erreurs et l'état d'envoi. Il ne sait rien des services : on lui donne une configuration, il la rend. -
Fieldne connaît qu'un champ. C'est le traducteur config → HTML, et le seul endroit du projet qui sait qu'un"phone"devient untype="tel".
Un détail à savoir expliquer à l'oral : seul PriseDeRendezVous porte la
directive 'use client'. Les trois autres n'en ont pas besoin, parce que
la directive ne marque pas « ce fichier est un composant client » mais « ici commence le
sous-arbre client » — tout ce qui est importé depuis un composant client devient
automatiquement du code client (module Server et Client Components). En mettre partout n'est
pas fatal, mais c'est du bruit ; savoir dire pourquoi une seule suffit est un signal
de compréhension fort.
Où vit l'état, et pourquoi là
La question d'architecture la plus probable à l'oral. Deux états, deux endroits, deux raisons.
Le service choisi vit dans PriseDeRendezVous. Pourquoi si
haut ? Parce que deux composants frères en dépendent : le sélecteur, qui affiche la
valeur courante, et le formulaire, qui existe ou non selon elle. Quand une donnée est
nécessaire à deux frères, elle vit chez leur premier ancêtre commun — le lifting state
up, règle générale et pas astuce. La descendre dans le sélecteur l'obligerait à prévenir
son parent d'une valeur qu'il détient lui-même : deux copies de la vérité.
Les valeurs saisies vivent dans DynamicForm. Pourquoi
si bas ? Parce que personne d'autre n'en a besoin : le sélecteur se moque de ce que
tu tapes dans « Email », et les remonter ferait redessiner le sélecteur à chaque frappe. La
règle miroir : un état descend aussi bas que possible et remonte seulement quand
quelqu'un d'autre en a besoin. Ces deux phrases répondent à quatre-vingt pour cent
des questions d'architecture posées en entrevue junior.
Les valeurs saisies ont été remontées d'un cran, dans le composant qui possède déjà le service. Cite deux conséquences, dont une que l'utilisateur voit.
export function PriseDeRendezVous({ services, configurations }: Props) {
const [service, setService] = useState('');
const [values, setValues] = useState<FormValues>({}); // ← remonté ici
return (
<section>
<ServiceSelector services={services} value={service} onChange={setService} />
<DynamicForm values={values} onChange={setValues} />
</section>
);
}
Un — invisible : chaque frappe met à jour un état du parent, donc redessine le parent, donc redessine aussi le sélecteur, qui n'a rien à voir avec ce qu'on tape. Sur cinq champs, personne ne le remarquera ; le principe reste faux, et il se paie dès que le voisin devient coûteux. La règle miroir de « l'état monte quand plusieurs frères en ont besoin » est celle-ci : il redescend aussi bas que possible quand personne d'autre n'en a besoin.
Deux — bien visible : les valeurs survivent maintenant au changement de service, puisque le composant qui les détient, lui, n'est jamais démonté. L'utilisateur remplit le formulaire d'imagerie dentaire, bascule sur l'échographie, et retrouve ses anciennes réponses dans un formulaire qui n'a plus les mêmes champs : certaines valeurs restent dans l'objet sans être affichées nulle part, et partiraient telles quelles à la soumission. En laissant l'état dans le formulaire, le problème n'existe simplement pas — c'est la même décision d'architecture qui gouverne les rendus et la remise à zéro.
Le typage : une union discriminée sur le type de champ
Reste à décrire un champ en TypeScript. La solution paresseuse —
{ name: string; label: string; type: string; options?: string[] } — compile et
est mauvaise pour deux raisons : type: string autorise n'importe quelle chaîne
(les fautes de frappe passent), et options devient facultatif partout,
y compris là où il est obligatoire — donc tu écris field.options?.map(...) même
dans la branche où tu sais que les options existent.
La bonne modélisation est une union discriminée : on ne décrit pas « un champ
avec des options facultatives » mais « soit un champ simple, soit une liste déroulante qui a
forcément des options ». type sert de discriminant : dès que TypeScript
voit field.type === 'dropdown', il sait de quelle branche il s'agit et débloque
field.options comme un tableau garanti présent.
C'est exactement le patron décortiqué dans le module Lire les types TypeScript du premier guide, sous son autre nom : le type somme. Là-bas, on l'utilisait pour modéliser un résultat qui est « soit un succès avec une donnée, soit un échec avec un message » — et le bénéfice était le même : TypeScript refuse que tu lises la donnée avant d'avoir vérifié que c'est bien un succès.
Ici le discriminant est type au lieu de ok, mais la mécanique est
identique et le gain très concret dans Field : à l'intérieur du
case 'dropdown', field.options est un string[], pas
un string[] | undefined. Aucun ?., aucune assertion. Principe
général : quand deux formes de données ne peuvent pas coexister, ne les fusionne
pas dans un seul type à champs facultatifs — décris-les comme une alternative, et
laisse le compilateur te guider branche par branche.
Tu reçois l'énoncé un lundi matin, tu as jusqu'au mercredi soir, et tu estimes pouvoir travailler six heures en tout. Un camarade te dit : « commence par les bonus, c'est ça qui impressionne, les exigences de base c'est facile tu les feras à la fin ».
Questions : (1) Pourquoi ce conseil est-il faux ? (2) Qu'est-ce que l'évaluateur regarde en premier quand il ouvre ton dépôt ?
Voir le corrigé
(1) Parce que les cinq exigences ne sont pas « faciles », elles sont éliminatoires. Un test qui livre trois bonus brillants mais dont le formulaire ne se réinitialise pas après envoi rate une exigence explicite de l'énoncé — et un point manqué sur une consigne écrite noir sur blanc coûte infiniment plus cher qu'un bonus absent, parce qu'il ne dit pas « il a manqué de temps », il dit « il n'a pas lu ». Les bonus sont annoncés par ordre de priorité justement parce qu'aucun n'est attendu : ils départagent des candidats déjà valides.
(2) Il vérifie d'abord que ça démarre et que le parcours principal fonctionne de bout en bout : installer, lancer, choisir un service, remplir, envoyer, voir le formulaire se vider. Tout le reste — élégance du découpage, typage, bonus — n'est lu que si ce parcours-là tient. C'est aussi pour ça que la relecture des dix dernières minutes, dans le module Étude de cas : la solution disséquée, commence par « rejoue le parcours complet ».
« Raconte-moi comment tu as abordé le test. Par quoi as-tu commencé ? »
« Je n'ai pas commencé par coder. J'ai d'abord ouvert les deux fichiers de données,
parce que c'est eux qui imposent la structure : en les lisant, j'ai vu trois
choses que l'énoncé ne disait pas. Un, les type du JSON ne correspondent
pas aux types d'input HTML — il fallait donc une table de traduction.
Deux, il y a une configuration "*" qui sert de repli, ce qui impose de
chercher la configuration exacte d'abord et de retomber sur le joker seulement
ensuite. Trois, deux services n'ont aucune configuration propre, donc ce repli n'est
pas un cas théorique : il se déclenche vraiment.
Ensuite seulement j'ai listé les cinq exigences et décidé de ne toucher aux bonus qu'une fois les cinq validées. Ça m'a aussi fait décider ce que je ne ferais pas, et pouvoir l'expliquer : c'est plus solide que de livrer trois demi-fonctionnalités. »
Une seule de ces deux lignes compile. Laquelle, et qu'est-ce que ça t'apprend sur ce que fait le compilateur en franchissant le if ?
type FieldConfig =
| { name: string; label: string; type: 'text' | 'email' | 'phone' | 'textarea' }
| { name: string; label: string; type: 'dropdown'; options: string[] };
function countOptions(field: FieldConfig): number {
if (field.type === 'dropdown') {
return field.options.length; // (a)
}
return field.options.length; // (b)
}
(a) compile, (b) non. Le champ type est le discriminant : en lisant field.type === 'dropdown', TypeScript sait qu'à l'intérieur du if une seule des deux formes reste possible. Il y remplace donc FieldConfig par la branche « liste déroulante », où options est déclaré string[], tout court — pas string[] | undefined. Aucun ?., aucune assertion, rien à vérifier : c'est garanti par la forme du type.
Après le if, c'est l'autre branche qui reste, et l'erreur y est plus intéressante qu'elle n'en a l'air : ce n'est pas « peut-être undefined », c'est « la propriété options n'existe pas ». Toute la différence avec un type à champs facultatifs tient là. Avec options?: string[], les deux lignes compileraient — la (b) planterait à l'exécution sur le premier champ texte venu.
Et le bénéfice qu'on ne voit pas encore : le jour où le JSON gagnera un sixième type de champ, c'est ce même mécanisme qui te dira où le compilateur ne sait plus conclure.
Un test technique se gagne avant la première ligne de code. On lit
l'énoncé en séparant les exigences (éliminatoires) des
bonus (départage), on ouvre les données parce qu'elles
imposent des contraintes que l'énoncé tait, et on décide explicitement ce qu'on ne fera
pas. Les trois découvertes de ce module — la traduction des types, l'ordre imposé par le
repli "*", les services sans configuration — viennent toutes de la lecture
d'un fichier JSON, pas de l'énoncé.
L'architecture qui en découle tient en trois décisions défendables : le découpage en composants et l'endroit où passe la frontière client, l'endroit où vit l'état, et un typage en union discriminée qui rend les états impossibles irreprésentables.
Et ailleurs : cette méthode ne concerne pas que les tests techniques. Lire les données avant d'écrire le code, séparer l'obligatoire de l'optionnel, et savoir nommer ce qu'on a délibérément laissé de côté — c'est exactement ce qu'on attend d'un développeur devant un ticket un peu flou, en stage comme après.
- Les trois questions de presque tous les barèmes, dans l'ordre
- est-ce que ça démarre et que le parcours principal passe — c'est éliminatoire ; est-ce lisible en ouvrant trois fichiers au hasard ; sais-tu l'expliquer
- Ce que « deux heures » veut dire dans un énoncé
- un périmètre, presque jamais un chronomètre. Le message caché est « nous ne nous attendons pas à ce que tout soit fait »
- L'asymétrie entre une exigence ratée et un bonus absent
- l'exigence est une consigne écrite non suivie, et elle coûte le même prix ratée de peu ou pas commencée ; le bonus absent ne dit rien de négatif — à condition d'être nommé dans le README
- Le geste qu'on saute le plus, avant de coder
- ouvrir les fichiers de données. On y cherche les clés obligatoires, les valeurs spéciales, et surtout les cas non couverts — l'entrée d'un fichier qui n'a rien en face dans l'autre
- Ce qu'un repli
"*"impose à ta recherche - un ordre : la correspondance exacte d'abord, le joker seulement ensuite. Chercher « le service ou l'étoile » en une passe donne le joker à tout le monde
- Ce que décide le composant qui détient un état
- pas seulement qui se redessine, mais aussi la remise à zéro : un état placé trop haut survit à ce qui aurait dû le vider
- La règle miroir de « faire monter l'état »
- il redescend aussi bas que possible dès que personne d'autre n'en a besoin
- Ce que
type: stringlaisse passer dans un type - toutes les fautes de frappe, et il rend
options?facultatif partout — y compris là où il est obligatoire. La réponse est une union discriminée, qui débloque le champ sans?. - Ce que coûte une donnée externe sans identifiant stable
- la chaîne devient la clé partout — valeur,
key, correspondance, journalisation. La renommer d'un seul côté casse en silence, et elle ne rentre pas telle quelle dans une URL