Migrer en TypeScript sans bundler : retour sur Radar College

Radar College est une plateforme de quizz pour le Brevet des collèges que j'ai construite pour mon neveu. React, localStorage, radar SVG, gamification — le truc fonctionnait bien. Sauf qu'en ajoutant le 12e fichier de quizz, j'ai commencé à rencontrer des bugs silencieux. Un SUBJECT.id dupliqué entre trois fichiers. Un new Date(x) - new Date(y) qui renvoyait NaN dans certains cas. Des props passées à des composants qui n'existaient plus. Du JavaScript classique, en somme.

Le réflexe évident : ajouter TypeScript. Sauf que le projet a trois contraintes qui interdisent le moindre bundler. Et c'est là que ça devient intéressant.

Trois contraintes qui changent tout

Radar College n'est pas un projet "normal". Trois décisions prises au départ conditionnent toute l'architecture :

1. Zéro infrastructure. Le déploiement, c'est un scp vers un hébergement mutualisé OVH. Pas de Node sur le serveur, pas de CI/CD de déploiement, pas de Vercel. Un index.html et deux fichiers PHP pour la synchro optionnelle.

2. Compatible file://. Un parent doit pouvoir donner le fichier HTML à son enfant, double-cliquer dessus, et ça marche — sans serveur, sans wifi. C'est une vraie contrainte d'usage : tous les collégiens n'ont pas une connexion fiable à la maison. Conséquence directe : pas de history.pushState (qui lance un SecurityError en file://), donc hash routing uniquement.

3. Éditable sans rebuild. Les 12 fichiers de quizz (quizzes/*.tsx) doivent être modifiables directement — corriger une faute dans un énoncé, ajouter une question — sans avoir à installer Node, lancer un build, ou comprendre un pipeline. Un parent motivé peut le faire avec un éditeur de texte.

Ces trois contraintes éliminent webpack, vite, esbuild, et tout ce qui ressemble à un bundler avec un node_modules. Il faut une autre approche.

Babel Standalone : compiler le TSX dans le navigateur

La solution, c'est Babel Standalone : un build de Babel qui tourne dans le navigateur. Le script charge le fichier .tsx via un <script type="text/babel" data-presets="react,typescript">, Babel le compile en JavaScript à la volée, et React le monte dans le DOM.

Le coût : ~400 KB de téléchargement au premier chargement. Mais le Service Worker met tout en cache (stratégie cache-first pour les assets locaux, network-first pour les CDN), donc c'est transparent dès la deuxième visite. Et en mode file://, le SW se désactive proprement — Babel est chargé depuis le CDN, le reste est dans le HTML.

Le vrai problème, c'est que Babel Standalone a des bugs spécifiques au TypeScript qui n'existent pas dans le Babel classique. Deux en particulier m'ont fait perdre du temps :

// ❌ Babel Standalone ne gère pas useState<T>(value)
const [screen, setScreen] = useState<QuizPhase>('home');
// → Erreur de parsing : le < est interprété comme du JSX

// ❌ Les casts inline cassent aussi
const elapsed = (new Date() as any) - startTime;
// ✅ Workaround : fonctions factory pour typer les useState
const initialPhase = (): QuizPhase => 'home';
const [screen, setScreen] = useState(initialPhase);

// ✅ Workaround : variables intermédiaires pour les casts
const now: number = Date.now();
const elapsed = now - startTime;

C'est un pattern que j'ai dû appliquer sur les 10 useState du composant principal. Pas élégant, mais ça fonctionne et TypeScript infère correctement les types. Le compromis est acceptable : on perd la syntaxe idiomatique, on garde la sécurité de typage.

200 lignes de types sans import/export

Babel Standalone ne résout pas les imports. Pas de import { Question } from './types'. Tous les types doivent être en scope globale, déclarés en ambient dans un fichier types.ts chargé avant l'application.

// types.ts — déclarations ambiantes globales (extraits)
type Subject = 'maths' | 'physique' | 'svt';
type Level = '6eme' | '5eme' | '4eme' | '3eme';
type QuizKey = `${Subject}-${Level}`;  // template literal type
type QuizPhase = 'home' | 'quiz' | 'report';

interface Question {
    id: string;
    text: string;
    options: string[];
    correct: number;
    domain: string;
    hint?: string;
    method?: string;
}

interface DomainAnalysis {
    domain: string;
    label: string;
    total: number;
    correct: number;
    pct: number;
    status: 'acquired' | 'fragile' | 'not-acquired';
}

interface Attempt {
    date: string;
    score: number;
    weighted: number;
    total: number;
    domains: DomainAnalysis[];
    mode: 'training' | 'exam';
}

Le fichier fait ~200 lignes et couvre tout : les données (Question, QuizConfig, Attempt), l'état runtime (AnswersMap, TimingsMap, HintsMap — tous des Record<string, ...>), les résultats (DomainAnalysis, AnalyzeResult), et les props React de chaque composant.

Le type le plus utile, c'est QuizKey : un template literal type `${Subject}-${Level}` qui encode les 12 combinaisons valides. Impossible de passer 'maths-cm2' ou 'français-3eme' sans que TypeScript râle. Ce type seul aurait évité le bug de SUBJECT.id dupliqué.

Un build de 50 lignes

Le système de build est un script bash de 50 lignes avec un peu de Python inline. Pas de webpack, pas de vite, pas d'esbuild. Juste du remplacement de marqueurs dans un template HTML.

#!/bin/bash
# build.sh — inline tout dans un seul index.html

TEMPLATE="index.html"
OUTPUT="dist/index.html"

# 1. Lire le template
cp "$TEMPLATE" "$OUTPUT"

# 2. Inliner le CSS
CSS=$(cat app.css)
# Python pour le remplacement (sed galère avec le multiline)
python3 -c "
import sys
content = open('$OUTPUT').read()
css = open('app.css').read()
content = content.replace('/* __APP_CSS__ */', css)
# Inliner chaque fichier quiz
import glob
for f in sorted(glob.glob('quizzes/*.tsx')):
    key = f.replace('quizzes/','').replace('.tsx','')
    marker = f'/* __QUIZ_{key.upper()}__ */'
    content = content.replace(marker, open(f).read())
# Inliner app.tsx
app = open('app.tsx').read()
content = content.replace('/* __APP_TSX__ */', app)
open('$OUTPUT', 'w').write(content)
"

Le résultat : un index.html de ~300 KB qui contient tout — CSS, TypeScript des 12 quizz, code de l'application. Un seul fichier à déployer. Babel Standalone le compile dans le navigateur au chargement. Le Service Worker le cache. C'est fini.

Est-ce que c'est optimal ? Non. Est-ce que c'est suffisant pour une app utilisée par une poignée de collégiens ? Largement. Et surtout, c'est compréhensible. N'importe qui peut lire build.sh et comprendre ce qu'il fait. Essayez avec un webpack.config.js de 200 lignes.

L'IIFE et la course au montage

L'architecture de Radar College est un peu inhabituelle : le routing est en JavaScript vanilla dans le index.html, et l'application React est dans un app.tsx séparé, wrappé dans un IIFE pour l'isolation de scope.

Le problème, c'est que Babel Standalone compile le TSX de manière asynchrone, mais le routeur vanilla est synchrone. Quand l'utilisateur arrive sur #/3eme/maths/quiz, le routeur appelle window.mountQuizApp('maths-3eme') — sauf que la fonction n'existe pas encore, parce que Babel n'a pas fini de compiler.

// Solution : file d'attente sur window
// Côté routeur (vanilla JS) :
if (typeof window.mountQuizApp === 'function') {
    window.mountQuizApp(key);
} else {
    window.__pendingQuizMount = key;  // "je voulais monter ça"
}

// Côté React (app.tsx, après compilation Babel) :
window.mountQuizApp = (key: string) => {
    setActiveQuiz(key as QuizKey);
    root.render(<App />);
};

// Vérifier s'il y a un montage en attente
if (window.__pendingQuizMount) {
    window.mountQuizApp(window.__pendingQuizMount);
    delete window.__pendingQuizMount;
}

C'est du pur plomberie de communication inter-mondes. Dans un projet avec un bundler, tout serait dans le même scope et ce problème n'existerait pas. Mais la contrainte zero-build force à expliciter des choses qu'on tient normalement pour acquises.

48 tests E2E avec Playwright

Pas de tests unitaires. Que des tests end-to-end. Le choix peut surprendre, mais il est logique pour ce type de projet : l'essentiel de la valeur est dans les interactions utilisateur (sélectionner une réponse, voir son score, retrouver son historique), pas dans des fonctions pures isolables.

Les 48 scénarios couvrent 8 axes :

CatégorieTestsCe qu'on vérifie
Wizard / Landing11Saisie prénom, sélection niveau, mémoire entre visites, switch d'élève
Quiz11Modes entraînement/examen, indices, timer, navigation clavier
Rapport3Score, diagnostic par chapitre, radar, plan de révision
Dashboard4Accès, historique vide, badges
PWA3Manifest, Service Worker, icônes
Routing SPA8Hash URLs, deep links, back navigation, fallback sur hash invalide
Design / a11y4Cibles tactiles ≥ 44px, dark mode, police dyslexie, zéro erreur console
Parcours utilisateur4Flow complet, persistance mémoire, transitions quiz-à-quiz

Le test le plus utile pendant la migration TypeScript, c'est le "zéro erreur console". Playwright intercepte toutes les erreurs JavaScript, et le test échoue s'il en trouve une seule. Chaque erreur de typage qui passait à travers Babel se retrouvait capturée là. C'est un filet de sécurité brutal mais efficace quand on migre du JS vers du TS : si ça compile et que le test "zéro console error" passe, c'est que la migration n'a rien cassé.

// Test : aucune erreur console sur l'ensemble du parcours
test('no console errors during full user journey', async ({ page }) => {
    const errors: string[] = [];
    page.on('console', msg => {
        if (msg.type() === 'error') errors.push(msg.text());
    });

    // Parcours complet : wizard → quiz → rapport → dashboard
    await page.goto('/');
    await page.fill('#student-name', 'Test');
    await page.click('[data-level="3eme"]');
    await page.click('[data-subject="maths"]');
    // ... compléter le quiz ...

    expect(errors).toEqual([]);
});

Les tests tournent dans GitHub Actions sur chaque push. Le CI exécute aussi npx tsc --noEmit — vérification de types sans émission de code, puisque c'est Babel qui compile. Deux couches de validation : les types en statique, le comportement en E2E.

Ce que la migration a révélé

La migration TypeScript a été faite en un seul PR : 22 fichiers, +709/-182 lignes. Trois itérations de revue, score progressif de 7.5 à 8.2/10. Voici ce que les types ont révélé dans du code qui "marchait" :

Trois fichiers de quizz avaient le même SUBJECT.id. maths-4eme, physique-4eme et svt-4eme déclaraient id: 'maths'. En JavaScript, aucune erreur — les quizz se chargeaient, les questions s'affichaient, mais le dashboard mélangeait les résultats des trois matières de 4e. Le genre de bug qu'un utilisateur signalerait comme "mes scores sont bizarres" sans savoir l'expliquer.

Arithmétique sur les objets Date. new Date(a) - new Date(b) fonctionne en JavaScript parce que les objets Date sont implicitement convertis en nombres via valueOf(). TypeScript refuse cette conversion implicite, et à raison : si a est undefined (question jamais répondue), le résultat est NaN, et le calcul du temps moyen par question devient silencieusement faux.

Des props fantômes. Deux composants recevaient des props qui avaient été renommées dans le parent trois semaines plus tôt. Le code marchait parce que les props étaient optionnelles de fait (JavaScript ne râle pas quand tu passes des clés en trop à un objet). Mais ça signifiait que le composant utilisait sa valeur par défaut au lieu de la valeur réelle. TypeScript a détecté le mismatch.

Conclusion

La leçon n'est pas "utilisez TypeScript" — ça, tout le monde le sait déjà. La leçon, c'est que les contraintes architecturales d'un projet (zero-build, file://, pas de bundler) ne sont pas une excuse pour se passer de typage. Babel Standalone + des déclarations ambiantes, ce n'est pas la solution idéale, mais c'est une solution qui fonctionne — et les bugs trouvés pendant la migration ont prouvé qu'elle valait le coup.

Le code source est sur GitHub. Les 48 tests passent. Les types compilent. Et mon neveu ne sait toujours pas que la plateforme qu'il utilise tous les soirs compile du TypeScript dans son navigateur.

Commentaires (0)