Migrating to TypeScript Without a Bundler: The Radar College Story

Radar College is a quiz platform for French middle school students preparing for the "Brevet" exam. React, localStorage, SVG radar chart, gamification — the thing worked well. Until I added the 12th quiz file and started running into silent bugs. A duplicated SUBJECT.id across three files. A new Date(x) - new Date(y) returning NaN in edge cases. Props passed to components that no longer existed. Classic JavaScript, in other words.

The obvious fix: add TypeScript. Except the project has three constraints that rule out any bundler whatsoever. And that's where things get interesting.

Three constraints that change everything

Radar College isn't a "normal" project. Three decisions made at the start dictate the entire architecture:

1. Zero infrastructure. Deployment is an scp to shared PHP hosting. No Node on the server, no CI/CD pipeline, no Vercel. One index.html and two PHP files for optional sync.

2. file:// compatible. A parent must be able to hand the HTML file to their kid, double-click it, and have it work — no server, no wifi. This is a real usage constraint: not every student has reliable internet at home. Direct consequence: no history.pushState (throws SecurityError on file://), so hash routing only.

3. Editable without rebuild knowledge. The 12 quiz files (quizzes/*.tsx) must be directly editable — fix a typo in a question, add a new one — without installing Node, running a build, or understanding a pipeline. A motivated parent can do it with a text editor.

These three constraints eliminate webpack, vite, esbuild, and anything with a node_modules. A different approach was needed.

Babel Standalone: compiling TSX in the browser

The solution is Babel Standalone: a browser build of Babel. The script loads the .tsx file via a <script type="text/babel" data-presets="react,typescript"> tag, Babel compiles it to JavaScript on the fly, and React mounts it in the DOM.

The cost: ~400 KB on first load. But the Service Worker caches everything (cache-first for local assets, network-first for CDNs), so it's transparent from the second visit onwards. In file:// mode, the SW gracefully disables itself — Babel loads from CDN, the rest is in the HTML.

The real problem is that Babel Standalone has TypeScript-specific bugs that don't exist in regular Babel. Two in particular cost me time:

// ❌ Babel Standalone can't handle useState<T>(value)
const [screen, setScreen] = useState<QuizPhase>('home');
// → Parse error: the < is interpreted as JSX

// ❌ Inline casts also break
const elapsed = (new Date() as any) - startTime;
// ✅ Workaround: factory functions to type useState
const initialPhase = (): QuizPhase => 'home';
const [screen, setScreen] = useState(initialPhase);

// ✅ Workaround: intermediate variables for casts
const now: number = Date.now();
const elapsed = now - startTime;

This pattern was applied to all 10 useState calls in the main component. Not elegant, but it works and TypeScript infers the types correctly. The trade-off is acceptable: lose the idiomatic syntax, keep the type safety.

200 lines of types without import/export

Babel Standalone doesn't resolve imports. No import { Question } from './types'. All types must be globally scoped, declared as ambient in a types.ts file loaded before the application.

// types.ts — global ambient declarations (excerpts)
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';
}

The file is ~200 lines and covers everything: data types (Question, QuizConfig, Attempt), runtime state (AnswersMap, TimingsMap, HintsMap — all Record<string, ...>), results (DomainAnalysis, AnalyzeResult), and React props for each component.

The most useful type is QuizKey: a template literal type `${Subject}-${Level}` encoding the 12 valid combinations. Impossible to pass 'maths-cm2' or 'french-3eme' without TypeScript complaining. This type alone would have prevented the duplicated SUBJECT.id bug.

A 50-line build system

The build system is a 50-line bash script with some inline Python. No webpack, no vite, no esbuild. Just marker replacement in an HTML template.

#!/bin/bash
# build.sh — inline everything into a single index.html

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

# 1. Read the template
cp "$TEMPLATE" "$OUTPUT"

# 2. Inline CSS
CSS=$(cat app.css)
# Python for replacement (sed struggles with multiline)
python3 -c "
import sys
content = open('$OUTPUT').read()
css = open('app.css').read()
content = content.replace('/* __APP_CSS__ */', css)
# Inline each quiz file
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())
# Inline app.tsx
app = open('app.tsx').read()
content = content.replace('/* __APP_TSX__ */', app)
open('$OUTPUT', 'w').write(content)
"

The result: a ~300 KB index.html that contains everything — CSS, TypeScript for all 12 quizzes, application code. One file to deploy. Babel Standalone compiles it in the browser at load time. The Service Worker caches it. Done.

Is it optimal? No. Is it sufficient for an app used by a handful of students? Absolutely. And more importantly, it's understandable. Anyone can read build.sh and understand what it does. Try that with a 200-line webpack.config.js.

The IIFE and the mount race condition

Radar College's architecture is a bit unusual: routing is vanilla JavaScript in the index.html, and the React app lives in a separate app.tsx, wrapped in an IIFE for scope isolation.

The problem is that Babel Standalone compiles TSX asynchronously, but the vanilla router is synchronous. When the user lands on #/3eme/maths/quiz, the router calls window.mountQuizApp('maths-3eme') — except the function doesn't exist yet because Babel hasn't finished compiling.

// Solution: pending queue on window
// Router side (vanilla JS):
if (typeof window.mountQuizApp === 'function') {
    window.mountQuizApp(key);
} else {
    window.__pendingQuizMount = key;  // "I wanted to mount this"
}

// React side (app.tsx, after Babel compilation):
window.mountQuizApp = (key: string) => {
    setActiveQuiz(key as QuizKey);
    root.render(<App />);
};

// Check for pending mount
if (window.__pendingQuizMount) {
    window.mountQuizApp(window.__pendingQuizMount);
    delete window.__pendingQuizMount;
}

Pure plumbing for cross-world communication. In a bundled project, everything would share the same scope and this problem wouldn't exist. But the zero-build constraint forces you to make explicit things you normally take for granted.

48 E2E tests with Playwright

No unit tests. Only end-to-end tests. The choice may surprise, but it makes sense for this type of project: the value lies in user interactions (selecting an answer, viewing a score, retrieving history), not in isolated pure functions.

The 48 scenarios cover 8 areas:

CategoryTestsWhat's verified
Wizard / Landing11Name input, level selection, memory across visits, student switching
Quiz11Training/exam modes, hints, timer, keyboard navigation
Report3Score, per-chapter diagnosis, radar, revision plan
Dashboard4Access, empty history, badges
PWA3Manifest, Service Worker, icons
SPA Routing8Hash URLs, deep links, back navigation, invalid hash fallback
Design / a11y4Tap targets ≥ 44px, dark mode, dyslexia font, zero console errors
User journeys4Full flow, memory persistence, quiz-to-quiz transitions

The most useful test during the TypeScript migration was "zero console errors". Playwright intercepts all JavaScript errors, and the test fails if it finds a single one. Every typing error that slipped past Babel got caught there. It's a brutal but effective safety net when migrating JS to TS: if it compiles and the "zero console error" test passes, the migration hasn't broken anything.

// Test: no console errors during full user journey
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());
    });

    // Full flow: wizard → quiz → report → dashboard
    await page.goto('/');
    await page.fill('#student-name', 'Test');
    await page.click('[data-level="3eme"]');
    await page.click('[data-subject="maths"]');
    // ... complete the quiz ...

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

Tests run in GitHub Actions on every push. The CI also runs npx tsc --noEmit — type checking without code emission, since Babel handles compilation. Two validation layers: types statically, behavior via E2E.

What the migration revealed

The TypeScript migration was done in a single PR: 22 files, +709/-182 lines. Three review iterations, progressive score from 7.5 to 8.2/10. Here's what the types revealed in code that "worked":

Three quiz files had the same SUBJECT.id. maths-4eme, physique-4eme, and svt-4eme all declared id: 'maths'. In JavaScript, no error — quizzes loaded, questions displayed, but the dashboard mixed up results for all three 4th-grade subjects. The kind of bug a user would report as "my scores are weird" without being able to explain why.

Arithmetic on Date objects. new Date(a) - new Date(b) works in JavaScript because Date objects are implicitly converted to numbers via valueOf(). TypeScript rightfully refuses this implicit conversion: if a is undefined (unanswered question), the result is NaN, and the average time-per-question calculation becomes silently wrong.

Ghost props. Two components received props that had been renamed in the parent three weeks earlier. The code worked because the props were effectively optional (JavaScript doesn't complain when you pass extra keys to an object). But it meant the component used its default value instead of the actual one. TypeScript caught the mismatch.

Conclusion

The lesson isn't "use TypeScript" — everyone knows that already. The lesson is that a project's architectural constraints (zero-build, file://, no bundler) aren't an excuse to skip type safety. Babel Standalone + ambient declarations isn't the ideal solution, but it's a solution that works — and the bugs found during migration proved it was worth the effort.

The source code is on GitHub. All 48 tests pass. Types compile. And my nephew still doesn't know that the platform he uses every evening compiles TypeScript in his browser.

Comments (0)