Coder une todo-list en TypeScript vanilla : projet complet pour débutant
La todo-list est le projet d'apprentissage le plus rebattu du web, et c'est précisément pour ça qu'elle est irremplaçable : en une centaine de lignes, elle oblige à toucher tout ce qui fait une vraie application. Des données à structurer, une page à mettre à jour, des clics à écouter, une sauvegarde à gérer. Si vous avez suivi le projet Snake en JavaScript, vous connaissez déjà la recette du projet guidé ; cette fois, on change d'outil : tout sera écrit en TypeScript, sans aucun framework, avec le DOM du navigateur pour seul terrain de jeu.
Pourquoi TypeScript pour un projet aussi petit ? Parce que la todo-list concentre les deux endroits où JavaScript laisse passer des bugs silencieux : le DOM, où un sélecteur qui ne trouve rien renvoie null sans prévenir, et la sauvegarde, où des données relues depuis localStorage peuvent avoir n'importe quelle forme. Vous allez voir le compilateur intercepter cinq erreurs réelles avant même d'ouvrir le navigateur. Et vous verrez aussi, en fin d'article, un bug bien réel que le typage n'a pas vu du tout : les deux leçons se complètent.
Une précision de méthode, comme toujours ici : tout le code de cette page a été compilé avec TypeScript 7.0.2 (la version installée par npm au moment où j'écris) et testé dans Chrome 151 pendant la rédaction. Les messages d'erreur sont cités tels que tsc les affiche, en anglais et avec leur code, et les comportements décrits ont été mesurés dans la page, pas supposés.
Préparer le projet : trois fichiers et un compilateur
Le navigateur ne sait pas lire du TypeScript. C'est le point que beaucoup de débutants découvrent à leurs dépens : un fichier .ts référencé directement dans une balise <script> ne fonctionnera jamais. Il faut le compiler en JavaScript, et c'est le rôle du compilateur tsc. Notre projet tient donc en trois morceaux : le code source dans src/app.ts, sa version compilée dans dist/app.js, et une page index.html qui charge cette version compilée.
Installation : dans un dossier vide, on initialise un projet npm et on installe TypeScript en dépendance de développement. Chez moi, cette commande a installé la version 7.0.2 :
npm init -y
npm install --save-dev typescript
npx tsc --version
# Version 7.0.2Il faut ensuite un fichier tsconfig.json pour dire au compilateur quoi faire. La commande npx tsc --init en génère un, mais celui de TypeScript 7 est pensé pour un projet Node.js ; pour notre page web, il est plus simple et plus instructif de l'écrire à la main :
{
"compilerOptions": {
"target": "es2020",
"lib": ["es2020", "dom"],
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"sourceMap": true
}
}Deux lignes méritent votre attention. "lib": ["es2020", "dom"] charge les types du navigateur : c'est grâce à elle que le compilateur connaît document, localStorage et le type exact de chaque élément HTML. Et "strict": true active toutes les vérifications strictes ; c'est le réglage qui va nous rendre service pendant tout l'article, ne le désactivez jamais pour « faire passer » une erreur. Pendant le développement, lancez le compilateur en mode surveillance avec npx tsc --watch : il recompile à chaque sauvegarde, chez moi la sortie affiche File change detected. Starting incremental compilation... puis Found 0 errors. Watching for file changes.
Reste la page HTML. Elle est volontairement minimale : un formulaire d'ajout, une liste vide et un compteur. Si les balises de formulaire ne vous sont pas familières, le guide des formulaires HTML les reprend une par une.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ma todo-list</title>
</head>
<body>
<h1>Ma todo-list</h1>
<form id="nouvelle-tache">
<input id="texte" type="text" placeholder="Quelque chose à faire…" required>
<button type="submit">Ajouter</button>
</form>
<ul id="liste"></ul>
<p id="compteur"></p>
<script src="dist/app.js" defer></script>
</body>
</html>Notez le defer sur la balise script : il garantit que le code s'exécute après la construction de la page, donc que nos querySelector trouveront leurs éléments. J'ai ajouté une dizaine de lignes de CSS pour l'aération et le texte barré des tâches faites (un text-decoration: line-through sur les lignes cochées) ; libre à vous de styler davantage, les bases du CSS suffisent largement ici.
Le contrat de données : décrire une tâche avant de la coder
Premier réflexe TypeScript, et sans doute la meilleure habitude que ce langage installe : avant d'écrire la moindre logique, on décrit la forme des données. Une tâche, c'est un identifiant, un texte et un état fait ou pas fait. En TypeScript, cette description s'appelle une interface :
interface Tache {
id: number;
texte: string;
faite: boolean;
}
// L'état de l'application : un simple tableau de tâches.
let taches: Tache[] = [];
// Prochain identifiant à attribuer (repris au chargement, voir plus bas).
let prochainId = 1;Ces trois lignes d'interface ne produisent aucun JavaScript : elles disparaissent à la compilation. Leur seul rôle est de donner au compilateur un contrat à faire respecter, et il le fait avec un zèle très concret. J'ai essayé trois maladresses classiques, voici ce que tsc m'a répondu à chaque fois. Une faute de frappe sur un nom de propriété :
console.log(t.text.toUpperCase());
// error TS2551: Property 'text' does not exist on type 'Tache'.
// Did you mean 'texte'?Un objet créé en oubliant un champ :
const nouvelle: Tache = { id: 1, texte: "réviser Flexbox" };
// error TS2741: Property 'faite' is missing in type
// '{ id: number; texte: string; }' but required in type 'Tache'.Et une valeur du mauvais type glissée dans le tableau :
taches.push("acheter du pain");
// error TS2345: Argument of type 'string' is not assignable
// to parameter of type 'Tache'.Relisez le premier message : le compilateur ne se contente pas de refuser, il propose la bonne orthographe. En JavaScript, ces trois erreurs auraient produit des undefined silencieux ou des plantages à l'exécution, parfois longtemps après, dans une toute autre partie du code. Ici, elles n'ont pas survécu à la sauvegarde du fichier.
Attraper les éléments de la page sans se faire piéger par null
Notre code a besoin de quatre éléments : le formulaire, le champ texte, la liste et le compteur. Le réflexe JavaScript serait d'enchaîner les document.querySelector et de s'en servir directement. En mode strict, TypeScript refuse, et il a deux raisons distinctes de le faire. J'ai écrit la version naïve pour les voir toutes les deux :
const champ = document.querySelector("#texte");
console.log(champ.value);
// error TS18047: 'champ' is possibly 'null'.
// error TS2339: Property 'value' does not exist on type 'Element'.Première objection : si le sélecteur ne trouve rien, querySelector renvoie null, et le compilateur exige qu'on traite ce cas. Deuxième objection : même si l'élément existe, le type renvoyé est un Element générique, qui n'a pas de propriété value ; seul un HTMLInputElement en a une. C'est exactement le bug du sélecteur mal copié qui, en JavaScript, explose à l'exécution avec un Cannot read properties of null.
Mon premier correctif a été un simple garde en tête de fichier : récupérer les quatre éléments, puis if (!formulaire || !champ || ...) throw. Surprise au lancement de tsc : trois erreurs TS18047 ont subsisté (deux sur liste, une sur compteur, du type 'liste' is possibly 'null'.), toutes à l'intérieur des fonctions. L'explication vaut la peine d'être comprise : le compilateur ne propage pas une vérification faite au niveau du fichier dans les fonctions déclarées à côté, car rien ne lui prouve qu'elles ne seront pas appelées avant le garde. La solution propre est une petite fonction qui vérifie et renvoie l'élément d'un coup :
// Récupère un élément de la page, ou arrête tout s'il n'existe pas.
function recuperer<T extends Element>(selecteur: string): T {
const element = document.querySelector<T>(selecteur);
if (element === null) {
throw new Error(`Élément introuvable : ${selecteur}`);
}
return element;
}
const formulaire = recuperer<HTMLFormElement>("#nouvelle-tache");
const champ = recuperer<HTMLInputElement>("#texte");
const liste = recuperer<HTMLUListElement>("#liste");
const compteur = recuperer<HTMLParagraphElement>("#compteur");Le <T extends Element> est un générique : la fonction accepte n'importe quel type d'élément et renvoie exactement celui qu'on lui demande, sans null possible puisqu'elle s'arrête net s'il manque. Si cette syntaxe vous intrigue, l'article sur les génériques TypeScript la démonte pas à pas ; retenez ici qu'après ces quatre lignes, tout le reste du fichier manipule des éléments dont le type précis est garanti. Les trois TS18047 ont disparu à la compilation suivante.
Une seule source de vérité : le tableau, et une fonction qui redessine tout
Le piège classique de la todo-list, c'est de manipuler le HTML directement : ajouter un <li> au clic, le retirer à la suppression, et se retrouver avec une page qui ne correspond plus aux données dès qu'on ajoute la sauvegarde. On va prendre le parti inverse, le même que celui des frameworks modernes : le tableau taches est la seule source de vérité, et une fonction afficher() reconstruit toute la liste à partir de lui, à chaque changement.
Voici la fonction. Elle vide la liste, recrée un <li> par tâche avec sa case à cocher, son texte et son bouton de suppression, puis met le compteur à jour :
function afficher(): void {
liste.innerHTML = "";
for (const tache of taches) {
const li = document.createElement("li");
if (tache.faite) {
li.classList.add("faite");
}
const caseACocher = document.createElement("input");
caseACocher.type = "checkbox";
caseACocher.checked = tache.faite;
caseACocher.addEventListener("change", () => basculer(tache.id));
const texte = document.createElement("span");
texte.textContent = tache.texte;
const bouton = document.createElement("button");
bouton.type = "button";
bouton.textContent = "Supprimer";
bouton.addEventListener("click", () => supprimer(tache.id));
li.append(caseACocher, texte, bouton);
liste.append(li);
}
const restantes = taches.filter((tache) => !tache.faite).length;
compteur.textContent = `${restantes} tâche(s) à faire sur ${taches.length}`;
sauvegarder();
}Reconstruire toute la liste à chaque fois peut sembler du gaspillage ; pour quelques dizaines de tâches, c'est instantané, et le gain de simplicité est énorme : l'affichage ne peut jamais être désynchronisé des données, puisqu'il en découle toujours. Remarquez aussi textContent plutôt que innerHTML pour le texte de la tâche : si quelqu'un tape du HTML dans le champ, il sera affiché comme du texte, pas interprété.
Ajouter, cocher, supprimer : trois fonctions courtes
L'ajout part du formulaire. On intercepte l'événement submit, on empêche le rechargement de page par défaut, et on refuse les textes vides :
function ajouter(texte: string): void {
const nouvelle: Tache = {
id: prochainId,
texte: texte,
faite: false,
};
prochainId = prochainId + 1;
taches.push(nouvelle);
afficher();
}
formulaire.addEventListener("submit", (evenement) => {
evenement.preventDefault();
const texte = champ.value.trim();
if (texte !== "") {
ajouter(texte);
champ.value = "";
champ.focus();
}
});Au banc d'essai dans Chrome : trois soumissions successives donnent bien trois lignes, le compteur affiche 3 tâche(s) à faire sur 3 et le champ est vidé puis refocalisé après chaque ajout, prêt pour la saisie suivante. Cocher et supprimer sont encore plus courts. Chaque ligne a mémorisé l'id de sa tâche dans ses écouteurs d'événements ; il suffit de retrouver la tâche dans le tableau :
function basculer(id: number): void {
const tache = taches.find((t) => t.id === id);
if (tache) {
tache.faite = !tache.faite;
afficher();
}
}
function supprimer(id: number): void {
taches = taches.filter((t) => t.id !== id);
afficher();
}Un détail de typage mérite le détour : taches.find(...) renvoie Tache | undefined, car rien ne garantit qu'une tâche porte cet id. Le if (tache) n'est donc pas une politesse, c'est le compilateur qui l'exige ; sans lui, tache.faite ne compile pas. Mesures dans la page : cocher la deuxième tâche fait passer sa ligne en classe faite, le style calculé de son texte affiche bien text-decoration-line: line-through, et le compteur tombe à 2 tâche(s) à faire sur 3. La suppression retire une seule ligne et le compteur suit.
Le bug que mon banc de test a trouvé : les ids jumeaux
Honnêteté totale : ma première version n'utilisait pas de compteur prochainId. Elle faisait comme la moitié des tutoriels, id: Date.now(), l'heure courante en millisecondes. Élégant en apparence, et mon banc de test automatisé l'a démoli en une seconde : en soumettant trois tâches coup sur coup, les ids enregistrés étaient 1787579992030, 1787579992032 et 1787579992032. Les deux dernières soumissions sont tombées dans la même milliseconde, et deux tâches se sont retrouvées avec le même identifiant.
Les conséquences, mesurées dans la page, sont exactement celles qu'on peut déduire du code : cliquer sur la case de la troisième tâche cochait la deuxième (le find s'arrête sur la première correspondance), et supprimer la deuxième faisait disparaître les deux (le filter écarte toutes les correspondances) ; ma liste passait de trois tâches à une seule sur un seul clic. Un humain qui tape ses tâches au clavier ne déclenchera probablement jamais deux ajouts dans la même milliseconde, mais un bug improbable reste un bug : le correctif est le compteur que vous avez vu plus haut, qui garantit des ids uniques par construction. Après correction, le banc mesure [1, 2, 3], chaque case coche sa propre tâche et chaque bouton ne supprime que la sienne.
Notez bien ce que cet épisode raconte : TypeScript n'a rien vu, et il n'avait rien à voir. Date.now() renvoie un number, mon interface demandait un number, le contrat était respecté à la lettre. Le typage attrape les erreurs de forme, pas les erreurs de logique ; il ne remplace pas le test, il le complète.
Sauvegarder dans le navigateur, et le piège de JSON.parse
Sans sauvegarde, la liste meurt à chaque rechargement. Le navigateur offre localStorage, un petit espace de stockage qui ne sait ranger que des chaînes de caractères : on y écrit donc le tableau converti en JSON. La fonction afficher() appelle déjà sauvegarder() à la fin, ce qui garantit que chaque changement est persisté :
function sauvegarder(): void {
localStorage.setItem("taches", JSON.stringify(taches));
}Vérification dans Chrome après trois ajouts et un cochage, la clé taches contient ceci (JSON.stringify produit une seule ligne, reformatée ici pour la lecture) :
[{"id":1,"texte":"acheter du pain","faite":false},
{"id":2,"texte":"réviser Flexbox","faite":true},
{"id":3,"texte":"publier l'article","faite":false}]Et après un rechargement de la page, les trois tâches réapparaissent, la deuxième toujours cochée, le compteur toujours à 2 tâche(s) à faire sur 3. La relecture, en revanche, est le passage le plus instructif de tout le projet. Le problème : JSON.parse renvoie le type any, celui qui désactive toutes les vérifications. Écrire taches = JSON.parse(brut) compile sans un mot, même si la chaîne stockée contient n'importe quoi. J'ai mesuré ce n'importe quoi dans la console : avec un objet à la place du tableau attendu, la boucle d'affichage s'effondre sur TypeError: taches is not iterable. Le compilateur n'a rien dit, et l'application est morte au chargement.
Or ce scénario n'a rien de théorique : une vieille version de votre code, une extension de navigateur ou une modification à la main dans les outils de développement suffisent à laisser dans localStorage une chaîne qui ne correspond plus à vos données. La parade tient en deux morceaux. D'abord typer le résultat de JSON.parse en unknown, le type qui oblige à vérifier avant d'utiliser. Ensuite écrire une fonction de contrôle qui examine réellement la valeur :
function estUneTache(valeur: unknown): valeur is Tache {
return (
typeof valeur === "object" &&
valeur !== null &&
typeof (valeur as Tache).id === "number" &&
typeof (valeur as Tache).texte === "string" &&
typeof (valeur as Tache).faite === "boolean"
);
}
function charger(): void {
const brut = localStorage.getItem("taches");
if (brut === null) {
return;
}
try {
const donnees: unknown = JSON.parse(brut);
if (Array.isArray(donnees) && donnees.every(estUneTache)) {
taches = donnees;
// Reprendre la numérotation après le plus grand id existant.
for (const tache of taches) {
if (tache.id >= prochainId) {
prochainId = tache.id + 1;
}
}
}
} catch {
// JSON illisible : on repart d'une liste vide.
}
}
charger();
afficher();Le valeur is Tache dans la signature s'appelle un prédicat de type : il promet au compilateur que si la fonction renvoie true, la valeur est bien une Tache. Grâce à lui, après le donnees.every(estUneTache), l'affectation taches = donnees compile sans forcer quoi que ce soit : la vérification faite à l'exécution et le typage racontent enfin la même histoire. La boucle finale reprend la numérotation des ids après le plus grand existant ; mesuré au banc, recharger une liste dont les ids vont jusqu'à 3 puis ajouter une tâche donne bien l'id 4, pas un doublon.
J'ai torturé cette version pour conclure : clé remplacée par {"pas":"un tableau"} puis rechargement, l'application démarre avec une liste vide au lieu de planter ; clé remplacée par du texte qui n'est même pas du JSON, le try/catch avale l'exception et même résultat, 0 tâche(s) à faire sur 0 et une application parfaitement utilisable.
Le fichier complet
Voici src/app.ts en entier, dans l'ordre où le fichier se lit : 134 lignes commentées, qui compilent en 108 lignes de JavaScript où plus aucune annotation de type ne subsiste.
// Une tâche de la liste : le contrat de données de toute l'application.
interface Tache {
id: number;
texte: string;
faite: boolean;
}
// L'état de l'application : un simple tableau de tâches.
let taches: Tache[] = [];
// Prochain identifiant à attribuer (repris au chargement, voir charger()).
let prochainId = 1;
// Récupère un élément de la page, ou arrête tout s'il n'existe pas.
function recuperer<T extends Element>(selecteur: string): T {
const element = document.querySelector<T>(selecteur);
if (element === null) {
throw new Error(`Élément introuvable : ${selecteur}`);
}
return element;
}
// Les quatre éléments de la page dont on a besoin.
const formulaire = recuperer<HTMLFormElement>("#nouvelle-tache");
const champ = recuperer<HTMLInputElement>("#texte");
const liste = recuperer<HTMLUListElement>("#liste");
const compteur = recuperer<HTMLParagraphElement>("#compteur");
// Redessine toute la liste à partir du tableau.
function afficher(): void {
liste.innerHTML = "";
for (const tache of taches) {
const li = document.createElement("li");
if (tache.faite) {
li.classList.add("faite");
}
const caseACocher = document.createElement("input");
caseACocher.type = "checkbox";
caseACocher.checked = tache.faite;
caseACocher.addEventListener("change", () => basculer(tache.id));
const texte = document.createElement("span");
texte.textContent = tache.texte;
const bouton = document.createElement("button");
bouton.type = "button";
bouton.textContent = "Supprimer";
bouton.addEventListener("click", () => supprimer(tache.id));
li.append(caseACocher, texte, bouton);
liste.append(li);
}
const restantes = taches.filter((tache) => !tache.faite).length;
compteur.textContent = `${restantes} tâche(s) à faire sur ${taches.length}`;
sauvegarder();
}
function ajouter(texte: string): void {
const nouvelle: Tache = {
id: prochainId,
texte: texte,
faite: false,
};
prochainId = prochainId + 1;
taches.push(nouvelle);
afficher();
}
function basculer(id: number): void {
const tache = taches.find((t) => t.id === id);
if (tache) {
tache.faite = !tache.faite;
afficher();
}
}
function supprimer(id: number): void {
taches = taches.filter((t) => t.id !== id);
afficher();
}
// --- Sauvegarde dans le navigateur ---
function sauvegarder(): void {
localStorage.setItem("taches", JSON.stringify(taches));
}
function estUneTache(valeur: unknown): valeur is Tache {
return (
typeof valeur === "object" &&
valeur !== null &&
typeof (valeur as Tache).id === "number" &&
typeof (valeur as Tache).texte === "string" &&
typeof (valeur as Tache).faite === "boolean"
);
}
function charger(): void {
const brut = localStorage.getItem("taches");
if (brut === null) {
return;
}
try {
const donnees: unknown = JSON.parse(brut);
if (Array.isArray(donnees) && donnees.every(estUneTache)) {
taches = donnees;
// Reprendre la numérotation après le plus grand id existant.
for (const tache of taches) {
if (tache.id >= prochainId) {
prochainId = tache.id + 1;
}
}
}
} catch {
// JSON illisible : on repart d'une liste vide.
}
}
// --- Démarrage ---
formulaire.addEventListener("submit", (evenement) => {
evenement.preventDefault();
const texte = champ.value.trim();
if (texte !== "") {
ajouter(texte);
champ.value = "";
champ.focus();
}
});
charger();
afficher();Ce que ce petit projet apprend vraiment
Faisons les comptes de la session. Le compilateur a intercepté cinq erreurs avant toute exécution : une faute de frappe avec la bonne orthographe en suggestion (TS2551), un champ oublié à la création d'un objet (TS2741), un mauvais type poussé dans le tableau (TS2345), un élément de page potentiellement absent (TS18047) et une propriété inexistante sur un type trop vague (TS2339). Chacune aurait été un plantage ou un comportement muet en JavaScript pur. En face, le bug le plus sournois du projet, les ids jumeaux de Date.now(), est passé sous son radar parce que c'était une erreur de logique, pas de forme, et c'est un banc de test qui l'a attrapé.
Mon avis après ce projet : pour du code qui touche au DOM et à des données sérialisées, le duo strict: true et unknown sur tout ce qui vient de l'extérieur vaut largement son coût d'apprentissage. Et si vous voulez prolonger l'exercice, le projet a des suites naturelles qui réutilisent tout ce que vous venez de voir : des boutons de filtre (toutes, à faire, faites) qui ne touchent que la fonction afficher(), l'édition d'une tâche au double-clic, ou une date limite ajoutée à l'interface Tache, que le compilateur vous forcera d'ailleurs à gérer partout où une tâche se crée. C'est exactement pour ça qu'on décrit ses données d'abord.